Superfici

Interactive liquid distortion

A tilted band of glass follows the pointer and refracts whatever is behind it, leaning into the direction of travel and straightening again when you stop. No WebGL — a clipped duplicate of the scene does the displacement.

How it works

Numbers

Band width
92px (half = 46px)
Max lean
±34px top vs bottom
Follow easing
lerp 0.12/frame (lower = heavier)
Displacement
scale(1.07), translateX = tilt × −0.85
Edges
1.5px, #00e5ff / #ff2ea8
Sheen
5-stop gradient, screen, 0.30

Prompt

Paste this into any coding model to get the effect from scratch. It states every number explicitly — vague words like “smooth” or “modern” produce a different result every time.

Build an interactive liquid-glass distortion in vanilla HTML/CSS/JS, no WebGL and no libraries. Render the scene twice: the page itself, and an identical full-size copy inside a .lens element that is clipped with clip-path: polygon() to a vertical band 92px wide whose top and bottom edges are offset in opposite directions by a --tilt variable. Do not rotate the lens box — clip a full-size element, so the copy inside needs no counter-transform. Apply transform: scale(1.07) translateX(calc(var(--tilt) * -0.85)) and filter: saturate(1.5) contrast(1.06) to the copy: that offset is the refraction. Overlay a five-stop iridescent linear-gradient at mix-blend-mode: screen (NOT color-dodge, which returns near-black over a dark backdrop and makes the sheen vanish), opacity 0.30, and draw two 1.5px hairlines along the band edges, #00e5ff on the left and #ff2ea8 on the right, rotated to match the lean. In JS, lerp the band's x toward the pointer at 0.12 per animation frame, and derive --tilt from the per-frame velocity (clamped to ±34px, itself eased at 0.1) so the band leans into movement and straightens when the pointer stops — sample velocity once per requestAnimationFrame, never per pointermove event. Under prefers-reduced-motion: reduce, park the band with a fixed tilt instead of hiding it.

Source

The whole file. It is what the sample above is running — copy it into an .html file and it works with nothing else.

interactive-liquid-distortion.html
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Interactive liquid distortion</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body {
    min-height: 100vh; overflow: hidden;
    background: #07070c;
    font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif;
  }

  .stage { position: relative; height: 100vh; isolation: isolate; cursor: crosshair; }

  /* The scene is written once, then duplicated verbatim into the lens. Anything
     that differs between the two copies shows up as a seam at the lens edge. */
  .scene {
    position: absolute; inset: 0;
    display: grid; place-items: center;
    background:
      radial-gradient(70% 90% at 20% 15%, #3b2bff55, transparent 60%),
      radial-gradient(60% 80% at 85% 80%, #ff2ea855, transparent 60%),
      #07070c;
  }
  .scene h1 {
    font-size: clamp(38px, 11vw, 104px);
    font-weight: 800; letter-spacing: -0.045em;
    color: #fff; line-height: 0.95; text-align: center;
  }
  .scene p {
    margin-top: 10px; text-align: center;
    font-size: 11px; letter-spacing: 0.34em; text-transform: uppercase;
    color: #ffffff66;
  }

  /* The lens is a full-size copy of the scene, clipped to a tilted band.
     Making it full-size and clipping (rather than a small rotated box) means
     the copy inside needs no counter-transform to stay registered with the
     original — the single biggest source of seams in this effect. */
  .lens {
    position: absolute; inset: 0; z-index: 2;
    --x: 50%;          /* band centre, follows the pointer */
    --tilt: 26px;      /* horizontal offset of top vs bottom = the lean */
    --half: 46px;      /* half the band width */
    clip-path: polygon(
      calc(var(--x) - var(--half) + var(--tilt)) 0%,
      calc(var(--x) + var(--half) + var(--tilt)) 0%,
      calc(var(--x) + var(--half) - var(--tilt)) 100%,
      calc(var(--x) - var(--half) - var(--tilt)) 100%
    );
  }
  /* The refraction itself: the copy is scaled and pushed against the lean, so
     whatever sits behind the band appears displaced. This is the whole trick —
     everything else on this page is decoration. */
  .lens .scene {
    transform: scale(1.07) translateX(calc(var(--tilt) * -0.85));
    filter: saturate(1.5) contrast(1.06) brightness(1.08);
  }
  /* Iridescent sheen + the bright edges that sell it as glass. */
  .lens::after {
    content: ""; position: absolute; inset: 0;
    background: linear-gradient(
      100deg,
      #ff3b8b 0%, #ffd93b 22%, #3bffd0 46%, #3b7bff 72%, #b93bff 100%);
    /* `screen` and not `color-dodge`. Dodge divides by the inverse of the
       backdrop, so over near-black — which is most of this scene — it returns
       near-black and the sheen is simply not there. It looks like the gradient
       failed to load. `screen` brightens toward the source colour whatever is
       underneath, which is what "glass catching the light" needs. */
    mix-blend-mode: screen;
    opacity: 0.3;
  }
  .edge {
    position: absolute; top: -5%; height: 110%; width: 1.5px; z-index: 3;
    pointer-events: none;
    /* The two edges get opposite tints. Real chromatic aberration splits the
       channels by wavelength; two coloured hairlines read as the same thing at
       a fraction of the cost. */
    transform-origin: 50% 50%;
  }
  .edge-l { background: linear-gradient(#00e5ff00, #00e5ffcc 30%, #00e5ffcc 70%, #00e5ff00); }
  .edge-r { background: linear-gradient(#ff2ea800, #ff2ea8cc 30%, #ff2ea8cc 70%, #ff2ea800); }

  .hint {
    position: absolute; left: 50%; bottom: 18px; transform: translateX(-50%);
    z-index: 4; font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase;
    color: #ffffff5c; pointer-events: none;
  }

  @media (prefers-reduced-motion: reduce) { .hint { display: none; } }
</style>

<div class="stage" id="stage">
  <div class="scene">
    <div>
      <h1>REFRACT</h1>
      <p>move the pointer</p>
    </div>
  </div>

  <div class="lens" id="lens">
    <div class="scene">
      <div>
        <h1>REFRACT</h1>
        <p>move the pointer</p>
      </div>
    </div>
  </div>

  <i class="edge edge-l" id="edgeL"></i>
  <i class="edge edge-r" id="edgeR"></i>
  <div class="hint">interactive liquid distortion</div>
</div>

<script>
  const stage = document.getElementById("stage");
  const lens = document.getElementById("lens");
  const edgeL = document.getElementById("edgeL");
  const edgeR = document.getElementById("edgeR");
  const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;

  const HALF = 46;        // half band width, px
  const MAX_TILT = 34;    // how far the band can lean, px
  const EASE = 0.12;      // 0-1 per frame: lower = heavier, more liquid

  let targetX = 0, x = 0, tilt = 0, lastX = 0, raf = 0;

  const place = () => {
    lens.style.setProperty("--x", x.toFixed(1) + "px");
    lens.style.setProperty("--tilt", tilt.toFixed(1) + "px");
    lens.style.setProperty("--half", HALF + "px");
    const deg = Math.atan2(tilt * 2, stage.clientHeight) * 180 / Math.PI;
    for (const [el, side] of [[edgeL, -1], [edgeR, 1]]) {
      el.style.left = (x + side * HALF) + "px";
      el.style.transform = `rotate(${-deg}deg)`;
    }
  };

  const tick = () => {
    const prev = x;
    x += (targetX - x) * EASE;
    // The lean comes from velocity, not position: the band trails the pointer
    // and straightens up again once you stop. Sampling velocity per frame here
    // (rather than per pointermove) keeps it stable when events come in bursts.
    const v = x - prev;
    tilt += (Math.max(-MAX_TILT, Math.min(MAX_TILT, v * 3.2)) - tilt) * 0.1;
    place();
    raf = Math.abs(targetX - x) > 0.05 || Math.abs(tilt) > 0.05 ? requestAnimationFrame(tick) : 0;
  };

  const setTarget = (clientX) => {
    targetX = clientX - stage.getBoundingClientRect().left;
    if (!raf) raf = requestAnimationFrame(tick);
  };

  targetX = x = stage.clientWidth / 2;
  place();

  if (reduce) {
    // Still show the lens, just parked and static: the point of the sample is
    // the refraction, and freezing it is better than hiding it entirely.
    tilt = 18; place();
  } else {
    stage.addEventListener("pointermove", (e) => setTarget(e.clientX));
    addEventListener("resize", () => place());
  }
</script>

Where it breaks

Seen in

@菜心视觉设计 (Douyin) · effects catalogued from a screen recording of alche.jp · 0:07