DistortionFree

Cursed Blood Goo

Liquid blood on a canvas: drops fall, merge and fill a pool at the bottom; your cursor or finger carries a glob that pulls them in.

CanvasMetaballsLiquid

Updated

Hover or click the scene to interact

This effect is free — the Distortion category contains 31 effects total, including 5 free. Effect.Labs has 840 vanilla effects. Explore the category →

Usage examples

Escape game · room 4 · 16+

The Bleeding Cellar

60 minutes to get out of Doctor Vorn's cellar. 2 to 6 players, every evening until 1 am.

Book a slot

① Horror escape game home page — background blood that follows the cursor

WhenThe Moricaille escape game site presents its “Bleeding Cellar” room: blood runs behind the title, the pool rises at the bottom of the page and the glob follows the visitor's mouse.
WhyThe mood is set before the first line of text, and visitors “touch” the room with their mouse. The text stays readable because the blood runs behind it.
SettingsScene stretched to the whole area (aspect-ratio: auto; height: 100%, square corners, black background), hint hidden; text laid on top with pointer-events: none so the pointer passes through, except the “Book a slot” button. Default colour.

VELMARO

Stretchy slime, 250 g tub

Ages 6+ · washable · no glitter

€6.90

② Try-me slime on a toy shop's product page

WhenOn the Velmaro slime page, the goo wobbles in a tray; the child (or parent) touches it with a finger or the mouse and picks its colour with three swatches: neon green, neon pink, glacier.
WhySwitching the colour live shows the product in all its versions without three photos; the glob sucking up drops makes people want to touch.
SettingsTray 54% of the width; data-color="#00ff00" to start; the swatches write #ff00ff or #00ffff into data-color, which the code reads on every frame. Pure colours chosen because the filter pushes each channel to 0 or 255. Hint replaced by “Touch the slime…”.

Horror film night

Saturday 31 October · 9 pm to 6 am · 6 films

TONIGHT'S PROGRAMME6 films

9 pmThe Salt Marsh House

11 pmThe Drowned Hold

1 amThe Twelfth Bell

③ Dripping divider between two sections of an event page

WhenOn the Sanguevry night page, a band of blood separates the announcement (black background) from the programme: drops fall, the pool forms at the bottom of the band and melts into the red “Tonight's programme” strip.
WhyThe transition between two sections becomes the page's signature; the rising pool visually ties the two blocks together.
SettingsBand 26% of the height (32% on mobile), square corners, black background, hint hidden; the next strip is #ff0000, the colour the filter actually gives #c81e2d, and overlaps the band by 7 px to cover the blurred edge.

How it works

The .goo-scene spans the full width at a 16/10 ratio. Its canvas draws solid discs (drops, pool, glob), then CSS applies filter: blur(7px) contrast(16): the blur makes shapes bleed into one another, the very strong contrast redraws a sharp edge. Two nearby drops then weld together like liquid. That contrast also pushes each colour channel towards 0 or 255: the default #c81e2d red shows as pure red.

Every 0.18 s, a drop with a 9 to 18 px radius is born at a random spot along the top and falls, speeding up. When it touches the pool it disappears and raises it; the pool drains slowly at the same time. So it grows quickly, then levels off: measured on an 800 px wide scene, about 14% of the height after 3 s, 20% at 6 s, 23 to 25% from 10 s on (cap: half). Its surface ripples continuously.

The pointer, mouse or finger, carries a large glob with a 34 px radius that follows with a slight lag. It pulls in drops less than 150 px away, harder the closer they are; when it moves fast, it flings new droplets in its direction; they fall back into the pool, which then rises higher (measured: close to half the height after 10 s of continuous finger movement). The scene holds at most 130 drops. Speeds are computed from elapsed time (capped at 50 ms): same pace at 60, 120 or 144 Hz.

The canvas follows the scene's size (ResizeObserver) at the display density, capped at 2. The loop stops when the scene leaves the screen or the tab is hidden. Each [data-goo] runs its own simulation: two copies on a page are independent (measured in Chromium 151, Firefox 153 and WebKit 26.5).

Accessibility

  • Reduced motion: under prefers-reduced-motion: reduce, no drop falls by itself any more and the pool stays still as long as nobody touches the scene; the scene opens on a pool and nine still drops. The gesture stays active: when you move the pointer or finger over it, the glob appears, pulls and flings drops, which fall back into the pool and raise it (measured: from 22% to 43% of the height after 10 s of finger movement); everything stops 1.5 s after your last move.
  • Touch: touch-action: none on the scene lets the finger drive the glob instead of scrolling the page (measured: 12 moves received, no cancellation, the glob under the finger). The page scrolls normally outside the scene; on mobile, leave some margin around it if the scene is large.
  • Keyboard: there is no keyboard equivalent to the glob. The effect is decorative: nothing important should depend on the gesture.
  • Screen readers: the canvas and the hint carry aria-hidden="true"; the scene is silent. If the blood carries meaning (a “bleeding” 404 page), say so in the nearby text.
  • Sensitive content: animated blood can upset people. On a general-audience page, pick another colour (data-color) or announce the theme.

Browser compatibility

Canvas 2D, CSS blur() + contrast() filter, aspect-ratio, Pointer Events and 6.6 KB of JavaScript. Minimum version set by aspect-ratio according to MDN data (Chrome 88, Firefox 89, Safari 15). Measured in Chromium 151, Firefox 153 and WebKit 26.5; finger input measured in Chromium in touch mode.

Chrome 88+✓ Full (measured)
Firefox 89+✓ Full (measured)
Safari 15+✓ Full (measured in WebKit)
Edge 88+✓ Full (Chromium engine)
Mobile iOS 15+✓ Not checked on a real iPhone
Android Chrome 88+✓ Full (finger measured in Chromium)

Without aspect-ratio, the scene has no height: give it a fixed one (height: 320px). Without JavaScript, the scene stays a dark rectangle.

The code

Copy the three blocks into your page. No dependencies.

HTML
<div class="fx-0803-root">
  <div class="goo-scene" data-goo data-color="#c81e2d">
    <canvas class="goo-canvas" aria-hidden="true"></canvas>
    <div class="goo-hint" aria-hidden="true">Sweep the blood…</div>
  </div>
</div>
CSS
.fx-0803-root,
.fx-0803-root *,
.fx-0803-root *::before,
.fx-0803-root *::after {
  box-sizing: border-box
}

.fx-0803-root {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  min-height: 200px;
  position: relative;
  background: rgb(10, 10, 15);
  color: rgb(255, 255, 255);
  font-family: sans-serif;
}

.goo-scene {
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 10;
  border-radius: 14px;
  overflow: hidden;
  isolation: isolate;
  background: #0b0406;
  cursor: crosshair;
  touch-action: none;
}

.goo-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  filter: blur(7px) contrast(16);
}

.goo-hint {
  position: absolute;
  top: 12px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 2;
  font-size: 12px;
  color: #ff5a5a;
  opacity: .85;
  white-space: nowrap;
  pointer-events: none;
}
JavaScript (fx-0803)
(function() {
  /* ============================================================
     Scoped JS (IIFE) — Cursed Blood Goo (fx-0803)
     Blood metaballs on each [data-goo]
  ============================================================ */
  (function() {
    var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
    var MAX = 130;

    document.querySelectorAll('.fx-0803-root [data-goo]').forEach(function(scene) {
      var canvas = scene.querySelector('.goo-canvas');
      var ctx = canvas.getContext('2d');
      var dpr = Math.min(window.devicePixelRatio || 1, 2);
      var W = 0, H = 0, drops = [], pool = 0, t = 0, drip = 0;
      var mx = -999, my = -999, on = false, bx = -999, by = -999, pmx = -999, pmy = -999;
      var visible = true, raf = 0, last = 0, awake = 0;

      function blob(x, y, r) {
        ctx.beginPath();
        ctx.arc(x, y, r, 0, 6.2832);
        ctx.fill();
      }

      function render() {
        if (!W || !H) return;
        ctx.fillStyle = '#0b0406';
        ctx.fillRect(0, 0, W, H);
        ctx.fillStyle = scene.dataset.color || '#c81e2d';
        // pool (rippling surface)
        var surf = H - pool;
        ctx.beginPath();
        ctx.moveTo(0, H + 2);
        ctx.lineTo(0, surf);
        for (var x = 0; x <= W + 18 * dpr; x += 18 * dpr) ctx.lineTo(x, surf + Math.sin(x * 0.012 + t * 3) * 6 * dpr);
        ctx.lineTo(W, H + 2);
        ctx.closePath();
        ctx.fill();
        // drops
        for (var i = 0; i < drops.length; i++) blob(drops[i].x, drops[i].y, drops[i].r);
        // pointer glob
        if (on) blob(bx, by, 34 * dpr);
      }

      function resize() {
        var w = Math.round(scene.clientWidth * dpr);
        var h = Math.round(scene.clientHeight * dpr);
        if (!w || !h || (w === W && h === H)) return;
        if (H) pool = pool * h / H;
        else if (reduce.matches) {
          // reduced motion: pool and drops in place from the start, nothing moves without the pointer
          pool = h * 0.22;
          for (var i = 0; i < 9; i++) drops.push({
            x: (0.12 + Math.random() * 0.76) * w,
            y: (0.2 + Math.random() * 0.45) * h,
            vx: 0,
            vy: 0,
            r: (12 + Math.random() * 14) * dpr
          });
        }
        W = canvas.width = w;
        H = canvas.height = h;
        render();
      }

      // f = frame duration in 60 fps frames (capped at 50 ms): same speed at 60, 120 or 144 Hz
      function step(f) {
        t += f / 60;
        // dripping from the top (one drop every 11/60 s), off under reduced motion
        if (!reduce.matches) {
          drip += f;
          while (drip >= 11) {
            drip -= 11;
            if (drops.length < MAX) drops.push({
              x: Math.random() * W,
              y: -12 * dpr,
              vx: (Math.random() - 0.5) * 0.5,
              vy: 1 + Math.random() * 1.4,
              r: (9 + Math.random() * 9) * dpr
            });
          }
        }
        var floorY = H - pool, damp = Math.pow(0.99, f);
        for (var i = drops.length - 1; i >= 0; i--) {
          var d = drops[i];
          d.vy += 0.28 * dpr * f;
          d.vx *= damp;
          if (on) {
            var ax = mx - d.x,
              ay = my - d.y,
              dist = Math.hypot(ax, ay);
            if (dist < 150 * dpr && dist > 1) {
              var k = (1 - dist / (150 * dpr)) * 0.7 * f;
              d.vx += ax / dist * k;
              d.vy += ay / dist * k;
            }
          }
          d.x += d.vx * f;
          d.y += d.vy * f;
          if (d.y + d.r * 0.4 >= floorY) {
            // each drop feeds the pool (share of the height, whatever the width)
            pool = Math.min(H * 0.5, pool + H * 0.012 * d.r * d.r / (169 * dpr * dpr));
            drops.splice(i, 1);
            continue;
          }
          if (d.x < -40 * dpr || d.x > W + 40 * dpr) drops.splice(i, 1);
        }
        // the pool drains slowly: it rises to an equilibrium (≈ 20 % of the height)
        pool -= pool * 0.3 * f / 60;
        if (on) {
          var e = 1 - Math.pow(0.7, f);
          bx += (mx - bx) * e;
          by += (my - by) * e;
          var sp = Math.hypot(mx - pmx, my - pmy) / f;
          if (sp > 13 * dpr && drops.length < MAX) drops.push({
            x: bx + (Math.random() - 0.5) * 22 * dpr,
            y: by + (Math.random() - 0.5) * 22 * dpr,
            vx: (mx - pmx) / f * 0.07,
            vy: (my - pmy) / f * 0.07 + 1,
            r: (6 + Math.random() * 6) * dpr
          });
        }
        pmx = mx;
        pmy = my;
      }

      function frame(now) {
        raf = 0;
        var f = last ? Math.min(now - last, 50) * 0.06 : 1;
        last = now;
        step(f);
        render();
        sync();
      }

      // loop paused off screen / hidden tab; under reduced motion it only runs for 1.5 s after a gesture
      function sync() {
        var run = visible && !document.hidden && W > 0 && (!reduce.matches || performance.now() < awake);
        if (run && !raf) raf = requestAnimationFrame(frame);
        if (!run) {
          if (raf) cancelAnimationFrame(raf);
          raf = 0;
          last = 0;
        }
      }

      function point(e) {
        var r = scene.getBoundingClientRect();
        mx = (e.clientX - r.left) * dpr;
        my = (e.clientY - r.top) * dpr;
        if (!on) {
          bx = pmx = mx;
          by = pmy = my;
        }
        on = true;
        awake = performance.now() + 1500;
        sync();
      }

      function leave() {
        on = false;
        mx = my = -999;
      }

      scene.addEventListener('pointerdown', point);
      scene.addEventListener('pointermove', point);
      scene.addEventListener('pointerleave', leave);
      scene.addEventListener('pointercancel', leave);

      resize();
      if ('ResizeObserver' in window) new ResizeObserver(function() {
        resize();
        sync();
      }).observe(scene);
      else window.addEventListener('resize', resize);
      if ('IntersectionObserver' in window) {
        new IntersectionObserver(function(entries) {
          visible = entries[0].isIntersecting;
          sync();
        }).observe(scene);
      }
      if ('MutationObserver' in window) new MutationObserver(render).observe(scene, {
        attributes: true,
        attributeFilter: ['data-color']
      });
      document.addEventListener('visibilitychange', sync);
      if (reduce.addEventListener) reduce.addEventListener('change', sync);
      sync();
    });
  })();
})();

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
data-color (on .goo-scene) #c81e2d (shown as pure red) Liquid colour, read on every frame: it can be changed live. The filter pushes each channel to 0 or 255: prefer pure colours (#00ff00, #ff00ff, #00ffff, #ffff00).
Shape (CSS — aspect-ratio of .goo-scene) 16 / 10 Replace with aspect-ratio: auto; height: 100% to fill a container, or a fixed height for a band.
Drop merging (CSS — filter of .goo-canvas) blur(7px) contrast(16) More blur (10–12 px) = softer drops that weld from further away; less (4 px) = sharper, separate drops.
Drip rate (JS — drip >= 11) One drop every 11/60 s 6 for a dense rain, 20 for a slow drip. The more drops, the higher the pool rises.
Pool height (JS — H * 0.012 and pool * 0.3) ≈ 25% of the height The first number is each drop's contribution, the second the draining speed. Double the contribution for a pool twice as high (cap: half the scene).
Attraction (JS — 150 * dpr and 0.7) 150 px radius, 0.7 strength Reach of the glob and strength of the pull. Set the strength to 0 for a glob that only flings.
Hint (HTML — .goo-hint) “Sweep the blood…” Replace the text or delete the tag.

FAQ

Why doesn't my colour show as is?

The contrast(16) that welds the drops multiplies each channel's distance from the middle by 16: anything above half goes to 255, the rest to 0. An orange #ff8000 comes out roughly orange, but a brown or a pastel turns red, yellow or white. Pick a pure colour, or lower the contrast (drop edges then get blurry).

Can the page still scroll on mobile?

Yes, outside the scene. On the scene itself the finger drives the glob (touch-action: none): that is deliberate, otherwise the browser takes over the gesture to scroll and the glob does not follow. For a full-screen scene on mobile, leave a margin or reduce its height.

Can the pool be emptied?

It drains all the time and only holds up thanks to the falling drops: raise the 11 in drip >= 11 (fewer drops) for a lower pool, or set a very large value to let it empty. Under reduced motion it only changes while you move over the scene. For a higher permanent pool, increase the H * 0.012 contribution.