交互式流体扭曲
一条倾斜的玻璃带跟着指针走,把身后的东西折射位移;朝移动方向倾倒,停下就自己扶正。没用 WebGL —— 位移是靠一份被裁剪的场景副本做的。
怎么做到的
- The scene is written twice: once as the page, once inside the lens. The lens copy is full-size and merely
clip-path-ed to a tilted band — not a small rotated box. That matters: a rotated box needs its contents counter-transformed to stay registered with the original, and any error there shows up as a visible seam along the band. - The refraction is one declaration on the copy:
scale(1.07) translateX(calc(var(--tilt) * -0.85)). Pushing the duplicate against the lean is what makes the content behind the band look bent. Everything else on the page is decoration. - The lean comes from velocity, not position. The band trails the pointer through a lerp, and the tilt is read from how far it moved this frame — so it leans while you drag and settles upright when you stop, which is the part that reads as liquid.
- Velocity is sampled once per animation frame, not per
pointermove. Pointer events arrive in bursts (and coalesced), so measuring per event makes the lean jitter on some machines and not others. - Chromatic aberration is faked with two 1.5px hairlines — cyan on one edge, magenta on the other. Splitting the actual colour channels would need a filter pass per channel for a difference nobody sees at this size.
- The iridescent sheen is
mix-blend-mode: screen, notcolor-dodge. Dodge divides by the inverse of the backdrop, so over the near-black that makes up most of this scene it returns near-black — the sheen is simply absent, and it reads as a gradient that failed to load.screenbrightens toward the source colour whatever sits underneath. I shipped the dodge version first and only caught it by looking at a screenshot.
参数
- 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
提示词
把这段丢给任意一个写代码的模型,就能从零长出这个效果。它把每个数值都写死了 ——「丝滑」「现代感」这种词每次生成出来的都不一样。
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.源码
整个文件。上面那个样板跑的就是它 —— 存成 .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>会翻车的地方
- Two copies of the scene means two of everything. Fine for a hero; if the scene contains a video, an iframe or a live canvas, the duplicate is a second decode and you should reach for a real displacement shader instead.
- The copy must be identical, including fonts and any animation phase. A web font that swaps in a moment later, or a duplicated CSS animation started at a different time, produces a band where the content does not line up — and it looks like a rendering bug rather than an authoring one.
mix-blend-modeforces the band onto its own compositing layer. On a page that already has many layers this is one more, and it is the first thing to profile if scrolling gets choppy.
出处
@菜心视觉设计 (Douyin) · effects catalogued from a screen recording of alche.jp · 0:07