Things you can grab, poke and peel能抓、能戳、能刮开的东西
An effect that responds to the pointer is not automatically interactive. What makes these five feel like objects is that they have boundaries, weight, and a defined state for "being held".对指针有反应,不等于可交互。让这五个效果像「东西」的,是它们有边界、有重量,而且「被抓住」是一个明确的状态。
Hit testing: square the distance, skip the square root
Every direct-manipulation effect starts with the same question: what is under the pointer. For circles it is one comparison:
Comparing squared distances avoids a Math.sqrt per object per event — which matters when you have a hundred bubbles and the pointer is moving at 120Hz. The radius is squared once, outside the loop.
The bug everyone hits: bounds must be in the same coordinate space as the pointer. e.clientX is viewport-relative; canvas drawing is element-relative. If the canvas is not at the top-left of the page, the hit test is silently offset by the canvas position, and the effect "works" but feels wrong — you have to click slightly to the left of what you mean.
Convert once at the event, not per object: const r = canvas.getBoundingClientRect() then x = e.clientX - r.left. Doing the conversion inside the loop is both slower and a common source of drift.
function hitTest(x, y, circles) {
for (let i = circles.length - 1; i >= 0; i--) { // topmost first
const c = circles[i];
const dx = x - c.x, dy = y - c.y;
if (dx * dx + dy * dy <= c.r * c.r) return c; // no sqrt
}
return null;
}
Bubbles: create on tap, destroy on tap, and do not leak
The bubble demo is a state machine per object: alive and drifting, then popped. The pop is a short animation, not an immediate removal — which means popped bubbles are still in the array for a few frames and must not be hit-testable again.
That is the classic bug: you pop a bubble, it plays its 300ms burst animation, and during those frames a second click on the same coordinates hits it again, plays the burst twice, and doubles the particle count. The fix is a dead flag checked in the hit test, not a splice — removing during iteration is how you get skipped indices.
Popping also needs to pay out something, or it does not feel like popping. The burst particles are the reward; without them the interaction is a click that makes a shape disappear, which reads as a bug rather than as feedback.
function pop(b) {
b.dead = true; // hide from hit testing immediately
for (let i = 0; i < 10; i++) {
const a = Math.random() * Math.PI * 2;
parts.push({ x: b.x, y: b.y, vx: Math.cos(a) * 3, vy: Math.sin(a) * 3, l: 1 });
}
}
// later: draw with decreasing alpha, then remove when the animation ends
if (b.dead && b.anim <= 0) bubbles.splice(i, 1);
Magnets: a spring with a radius
The magnetic button is the spring from the force fields guide, with one addition: it only applies inside a radius, and the strength falls off toward the edge.
The falloff is not optional. With a hard radius the button snaps when the pointer crosses the boundary and snaps back when it leaves — a visible discontinuity that reads as a glitch. With 1 - d/radius the pull grows from zero at the edge, so entering and leaving are both invisible.
Also note the damping. A spring with no damping takes a long time to settle, and during that time the button is still moving after the cursor has stopped — which feels laggy rather than snappy. 0.82 to 0.88 is the useful range for a UI element; below that it feels sticky, above it feels stiff.
function magnet(el, mx, my) {
const r = el.getBoundingClientRect();
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
const dx = mx - cx, dy = my - cy;
const d = Math.hypot(dx, dy) || 1;
const radius = 120;
if (d > radius) return; // outside the field
const pull = (1 - d / radius) * 0.35; // smooth entry
el.style.transform = 'translate(' + dx * pull + 'px,' + dy * pull + 'px)';
}
Using translate in px rather than a spring simulation keeps this cheap enough to run on pointermove; the spring version belongs in a rAF loop.
Spotlight: compositing is the mask
The spotlight could be done with a CSS mask, and the simpler version is a full-screen dark overlay with a hole in it. The hole is a radial gradient with a hard-ish stop:
The key insight is that you are not drawing light, you are drawing absence of dark. That is why the gradient goes from transparent at the centre to opaque at the edge — the centre is where the darkness is missing.
For a soft edge, give the gradient a stop at 0.6 with alpha 0 and another at 1.0 with alpha 1. For a hard edge, put both stops at the same offset, which produces a crisp circle with no feathering. Both are the same code with different numbers, which is why this effect scales from "flashlight" to "spotlight" without any rewriting.
If you need the spotlight to reveal content underneath (text, an image), draw the content first and the overlay second. If instead you want the content itself to be lit, put the overlay on a pseudo-element with mix-blend-mode: multiply and the dark areas will darken the content rather than cover it.
const g = ctx.createRadialGradient(mx, my, 0, mx, my, 140); g.addColorStop(0, 'rgba(0,0,0,0)'); // hole g.addColorStop(0.62, 'rgba(0,0,0,0)'); g.addColorStop(1, 'rgba(0,0,0,0.94)'); ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
Scratch cards: destination-out is the whole feature
The scratch demo is the only one here that modifies pixels rather than drawing over them. destination-out removes the destination where the source is opaque, which erases a stroke-shaped hole:
The trick is that the canvas must not be cleared between frames. Every stroke erases a bit more, and the accumulated erase is the reveal. So the loop draws the scratch layer once and then only adds erases — the opposite of the usual "clear and redraw" pattern.
The reveal percentage is worth computing, because it gives you the payoff moment: sample the canvas every so often with getImageData, count transparent pixels, and when the ratio passes 60% fade the whole layer out. Doing that every frame is far too slow; every tenth frame is invisible to the user and cheap.
One thing that catches people: the mouse should erase along a line, not at points. Recording only the current pointer position leaves gaps when the pointer moves fast. Interpolate between the previous and current position, or you get a dotted scratch that looks broken.
scratchCtx.globalCompositeOperation = 'destination-out'; scratchCtx.lineWidth = 34; scratchCtx.lineCap = 'round'; scratchCtx.beginPath(); scratchCtx.moveTo(lastX, lastY); // interpolate, don't just dot scratchCtx.lineTo(x, y); scratchCtx.stroke();
Dragging: pointer capture is not optional
The jelly ball is dragged, then springs back. The dragging part has one non-obvious requirement: setPointerCapture.
Without capture, dragging fast makes the pointer leave the element, the pointermove events stop arriving, and the object is dropped mid-drag. You then have to re-acquire on pointerup, which is exactly the bug where you have to "grab it again" after a fast swipe.
Capture also removes the need to listen on window, which is the usual workaround — and the workaround that leaks listeners if you forget to remove them.
The release physics is where the personality comes from. Two things: the spring target should be the original position, not the pointer position; and release velocity should carry over, so a flick throws the object rather than dropping it. Keeping the last two movement deltas and seeding the spring velocity with them is enough.
el.addEventListener('pointerdown', e => {
el.setPointerCapture(e.pointerId); // keeps events coming
dragging = true;
grabX = e.clientX - el.offsetLeft;
grabY = e.clientY - el.offsetTop;
});
el.addEventListener('pointermove', e => {
if (!dragging) return;
x = e.clientX - grabX; y = e.clientY - grabY;
});
el.addEventListener('pointerup', e => {
el.releasePointerCapture(e.pointerId);
dragging = false;
vx = lastDx; vy = lastDy; // carry the throw
});
Where to take this
Interaction is not "it reacts to the mouse". It is a boundary you can test, a state for being held, a release that carries momentum, and feedback when something is destroyed. Skip any of the four and the effect becomes a cursor follower.
命中检测:平方距离,跳过开方
所有直接操作都始于同一个问题:指针底下是什么。对圆来说,一次比较就够:
比较平方距离省掉每个对象每个事件一次 Math.sqrt——当你有一百个泡泡、指针以 120Hz 移动时,这件事有意义。半径在循环外面平方一次。
所有人都会踩的坑:边界必须和指针在同一坐标系里。e.clientX 是相对视口的,canvas 绘制是相对元素的。如果画布不在页面左上角,命中检测就被画布位置整体偏移了,效果「能用」但感觉不对——你得点在目标的偏左一点才有效。
在事件里转换一次,不要在循环里转:const r = canvas.getBoundingClientRect(),然后 x = e.clientX - r.left。放在循环里既慢,又是偏移漂移的常见来源。
function hitTest(x, y, circles) {
for (let i = circles.length - 1; i >= 0; i--) { // 从最上层开始
const c = circles[i];
const dx = x - c.x, dy = y - c.y;
if (dx * dx + dy * dy <= c.r * c.r) return c; // 不开方
}
return null;
}
泡泡:点一下生,点一下破,而且不许泄漏
泡泡示例里每个对象是一台状态机:活着、飘着,然后破掉。破是一个短动画,不是立刻删除——也就是说破掉的泡泡还会在数组里多待几帧,而这几帧里它绝不能再被命中。
这就是那个经典 bug:你戳破一个泡泡,它播放 300 毫秒的爆裂动画,这期间同一点第二次点击又命中它,动画播两遍,粒子数翻倍。修法是命中检测里查一个 dead 标记,而不是 splice——遍历过程中删除是索引被跳过的根源。
破还得「给出回报」,否则不像戳破。那一小团爆裂粒子就是回报;没有它,这次交互就只是「点一下让一个形状消失」,读起来像 bug,不像反馈。
function pop(b) {
b.dead = true; // 立刻从命中检测里消失
for (let i = 0; i < 10; i++) {
const a = Math.random() * Math.PI * 2;
parts.push({ x: b.x, y: b.y, vx: Math.cos(a) * 3, vy: Math.sin(a) * 3, l: 1 });
}
}
// 之后:边画边降透明度,动画结束时再移除
if (b.dead && b.anim <= 0) bubbles.splice(i, 1);
磁吸:一个有半径的弹簧
磁吸按钮就是力场那篇里的弹簧,加了一件事:只在半径内生效,而且强度向边缘衰减。
衰减不是可选项。硬半径会让按钮在越过边界时「啪」地吸过去、离开时「啪」地弹回——那个可见的不连续读起来像 bug。用 1 - d/radius,吸力从边缘的零开始增长,进出都看不见。
还要注意阻尼。没有阻尼的弹簧要很久才停,而这段时间里指针已经不动了按钮还在动——感觉是「拖沓」而不是「跟手」。UI 元素有用的区间是 0.82 到 0.88;更低发黏,更高发僵。
function magnet(el, mx, my) {
const r = el.getBoundingClientRect();
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
const dx = mx - cx, dy = my - cy;
const d = Math.hypot(dx, dy) || 1;
const radius = 120;
if (d > radius) return; // 半径外不管
const pull = (1 - d / radius) * 0.35; // 平滑进入
el.style.transform = 'translate(' + dx * pull + 'px,' + dy * pull + 'px)';
}
用 px 直接 translate,才便宜到能挂在 pointermove 上;真要弹簧模拟,得放进 rAF 循环。
聚光灯:合成操作本身就是遮罩
聚光灯也可以用 CSS mask 做,但更简单的版本是一整块暗色覆盖,上面开一个洞。洞是一个径向渐变,色标卡得比较硬:
关键认识是:你画的不是光,是「暗的缺席」。这就是为什么渐变从中心的透明走到边缘的不透明——中心是暗缺席的地方。
想要软边,就在 0.6 处放一个 alpha 0 的色标、1.0 处放 alpha 1;想要硬边,把两个色标放在同一个偏移上,得到一个没有羽化的清晰圆。同一段代码两组数字,所以这个效果从「手电筒」到「舞台灯」不用重写。
如果你需要聚光灯「揭示」下面的内容(文字、图片),就先画内容、再画覆盖层。反过来,如果你想让内容本身被照亮,就把覆盖层放到伪元素上、加 mix-blend-mode: multiply,暗的部分会压暗内容,而不是盖住它。
const g = ctx.createRadialGradient(mx, my, 0, mx, my, 140); g.addColorStop(0, 'rgba(0,0,0,0)'); // 洞 g.addColorStop(0.62, 'rgba(0,0,0,0)'); g.addColorStop(1, 'rgba(0,0,0,0.94)'); ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
刮刮乐:destination-out 就是全部功能
刮刮乐是这里唯一一个改像素而不是在像素上画的效果。destination-out 在源不透明处把目标抹掉,也就是擦出一个笔画形状的洞:
诀窍是画布不能在帧之间清空。每一笔都多擦掉一点,累积的擦除就是揭示。所以循环只画一次刮层,之后只往里加擦除——和通常「先清空再重画」的模式正好相反。
揭示比例值得算,因为它给你那个「完成」的时刻:隔一阵用 getImageData 采样,数透明像素,超过 60% 就把整层淡出。每帧都算太慢;每十帧算一次,用户看不出来,成本也低。
还有一个容易翻车的点:鼠标应该沿着一条线擦,而不是在一个点上擦。只记录当前指针位置,快速移动时就会留出空隙。要在上一个和当前位置之间插值,否则你得到的是虚线的刮痕,看起来像坏了。
scratchCtx.globalCompositeOperation = 'destination-out'; scratchCtx.lineWidth = 34; scratchCtx.lineCap = 'round'; scratchCtx.beginPath(); scratchCtx.moveTo(lastX, lastY); // 插值,别只画点 scratchCtx.lineTo(x, y); scratchCtx.stroke();
拖拽:指针捕获不是可选项
果冻球能被拖着走,松手弹回。拖这部分有一个不显眼的硬要求:setPointerCapture。
不做捕获,快速拖动时指针会离开元素,pointermove 就不再送到你手上,物体在中途被丢掉。你只好在 pointerup 时重新获取——这正是那种「快划一下之后得重新抓一次」的 bug。
捕获还免掉了在 window 上挂监听的变通做法——而那恰恰是忘了移除就会泄漏监听的变通做法。
松手的物理决定性格。两点:弹簧目标应该是原始位置,不是指针位置;释放速度要带走,于是一甩能把球扔出去,而不是「掉」在原地。留着最后两帧的位移增量,用它当弹簧初速度就够了。
el.addEventListener('pointerdown', e => {
el.setPointerCapture(e.pointerId); // 事件才不会断
dragging = true;
grabX = e.clientX - el.offsetLeft;
grabY = e.clientY - el.offsetTop;
});
el.addEventListener('pointermove', e => {
if (!dragging) return;
x = e.clientX - grabX; y = e.clientY - grabY;
});
el.addEventListener('pointerup', e => {
el.releasePointerCapture(e.pointerId);
dragging = false;
vx = lastDx; vy = lastDy; // 把甩出去的速度带走
});
接下来可以怎么用
交互不是「它对鼠标有反应」。它是一条能被检测的边界、一个「被抓住」的状态、一次带惯性的释放,以及东西被毁掉时的反馈。这四样少任何一样,效果就退化成一只跟着光标跑的东西。
Related demos相关示例
Each one runs on its own page, with the source shown below the stage.每个都在独立页面实时运行,舞台下方附完整源码。
Every effect on this site is a single self-contained file. Copy it, change one number, see what happens.本站每个特效都是单个自包含文件。复制走,改一个数字,看会发生什么。
Browse all 131 effects浏览全部 131 个特效