✨Effect Lab
Interaction交互

Treating the pointer as a signal, not an event把指针当作信号,而不是事件

A click is a discrete event: it happened, then it is over. Pointer movement is a stream, and everything interesting about these five effects comes from treating it that way — sampling it, buffering it, and letting it decay.点击是离散事件:发生了,然后结束了。指针移动是一条流,这五个效果里所有有意思的地方,都来自把它当成流来处理——采样它、缓冲它、让它的残留衰减。

The pointer stream outlives the event handler

The instinct is to draw inside the pointermove handler. That couples rendering to input: you draw as fast as the mouse fires events, which is faster than the display refreshes on a high-polling-rate mouse and slower when the user pauses.

The better shape separates the two. The handler does one thing — record the position. The render loop reads the record:

Now the render rate is yours, and the recorded position can be interpolated, smoothed, decayed, or read by five different effects at once. It also means nothing breaks when the pointer stops moving: the loop keeps running and the trail keeps fading, which is exactly the behaviour that makes it look alive.

This is also why pointermove beats mousemove. It unifies mouse, pen and touch in one event, and it gives you pointerType so you can change behaviour — a trail that follows a finger needs to be much more forgiving than one following a mouse.

record, then render
let px = 0, py = 0, hasPointer = false;

canvas.addEventListener('pointermove', e => {
  px = e.offsetX; py = e.offsetY; hasPointer = true;   // record only
});

function frame() {
  if (hasPointer) spawn(px, py);      // render reads the record
  drawAndDecay();
  requestAnimationFrame(frame);
}

Trails are a buffer with a death clock

A trail is not a line. It is a list of past positions, each with a remaining life, drawn as separate dots:

Two subtleties decide whether it looks smooth. First, decay must be proportional to time, not to frames — otherwise the trail length changes with the monitor refresh rate, and it looks different on a 144Hz display than on a 60Hz one. Second, spawn rate is separate from decay rate: slow spawning with slow decay gives a dotted line, fast spawning with fast decay gives a tight comet.

The neon pen is the same buffer drawn differently. Instead of dots, consecutive points are joined into path segments, and each segment fades independently. The line therefore dissolves from the tail while the head stays bright — the visual signature of something hot moving.

A detail that matters for the pen: it must break the path when the pointer leaves and re-enters. Otherwise a stroke drawn on the left and then on the right gets joined by a long diagonal, because the buffer does not know the pen was lifted.

position buffer with life
const pts = [];
function spawn(x, y) {
  pts.push({ x, y, l: 1 });
}
function drawAndDecay() {
  for (let i = pts.length - 1; i >= 0; i--) {
    const p = pts[i];
    p.l -= 0.02;
    if (p.l <= 0) { pts.splice(i, 1); continue; }
    ctx.globalAlpha = p.l;
    ctx.beginPath();
    ctx.arc(p.x, p.y, 6 * p.l, 0, 7);
    ctx.fill();
  }
}

Eyes are one subtraction and one clamp

The following eyes are the cheapest effect on the site and the most effective, because they exploit a very old reflex. The pupil is a point offset from its eye's centre along the direction of the pointer:

The clamp is the whole trick. Without it, the pupil slides out of the eyeball when the pointer is far away. With it, the pupil sits at the rim and stops — and that rim position is what reads as "looking at" rather than "pointing at".

The normalisation matters too: divide by the actual distance so the offset is a unit vector, then multiply by the maximum radius. That is the same operation as the force normalisation in the force fields guide, used for a completely different purpose.

The reason a row of eyes is unsettling rather than cute is timing: every eye reaches its clamp position at the same instant, so the whole row moves as one organism. Add a per-eye delay and it becomes friendly. That is a design lever, not a bug.

pupil offset, clamped
const dx = px - eyeX, dy = py - eyeY;
const d  = Math.hypot(dx, dy) || 1;
const maxOffset = 14;                          // eyeball radius - pupil radius
const k = Math.min(1, maxOffset / d) * maxOffset;
pupilX = eyeX + dx / d * Math.min(d, maxOffset);
pupilY = eyeY + dy / d * Math.min(d, maxOffset);

Constellations: connect what is close, and cap the cost

The constellation draws a line between every pair of nodes closer than a threshold, with opacity falling off with distance:

That is O(n²) and it is fine for sixty nodes — 1770 pair checks per frame, which is nothing. It stops being fine at a thousand nodes, where you would need a spatial grid; the demo deliberately keeps the count low rather than pretending to scale.

The distance falloff is what makes it look like a constellation rather than a mesh. With a hard cutoff, lines pop in and out at the threshold. With 1 - d/maxDist, a line fades to nothing exactly as the two nodes reach the limit — the transition is invisible, so the graph appears to grow and shrink organically.

One perf note that is easy to miss: setting strokeStyle per line is a state change per draw call. Grouping lines into a handful of opacity buckets (say eight) and setting the style eight times instead of a thousand is a large win for no visual difference.

pairwise links with falloff
for (let i = 0; i < nodes.length; i++) {
  for (let j = i + 1; j < nodes.length; j++) {
    const a = nodes[i], b = nodes[j];
    const d = Math.hypot(a.x - b.x, a.y - b.y);
    if (d > maxDist) continue;
    ctx.globalAlpha = 1 - d / maxDist;       // fade, don't pop
    ctx.beginPath();
    ctx.moveTo(a.x, a.y);
    ctx.lineTo(b.x, b.y);
    ctx.stroke();
  }
}

Ripples: staggered rings, not a growing circle

A water ripple looks like one circle expanding. It is actually several, spawned together with different start times and lifetimes:

If you draw a single ring that grows, you get a target reticle. The realism comes from the stagger — three or four rings at slightly different phases, each fading as it expands, so the disturbance looks like it has duration and a trailing wake.

The other ingredient is non-uniform expansion. A real ripple moves fast at the start and slows down. r = maxR * Math.sqrt(t) gives exactly that easing, and it costs one function call — this is the single change that makes an expanding circle stop looking like a UI animation.

staggered rings with sqrt easing
function ripple(cx, cy) {
  for (let i = 0; i < 4; i++) {
    waves.push({ x: cx, y: cy, t: -i * 0.12, max: 90 + i * 26 });
  }
}
// each frame
waves.forEach(w => {
  w.t += 0.02;
  if (w.t < 0) return;                       // not started yet
  const r = w.max * Math.sqrt(w.t);          // fast then slow
  ctx.globalAlpha = Math.max(0, 1 - w.t);
  ctx.beginPath();
  ctx.arc(w.x, w.y, r, 0, 7);
  ctx.stroke();
});

Where to take this

Store the pointer, do not act on it. Once input and rendering are separated, five different effects can read the same stream — and each one is about decay: a buffer that empties, an offset that clamps, a line that fades, a ring that slows down.

指针的流比事件处理器活得久

直觉是在 pointermove 里直接画。这把渲染和输入绑死了:鼠标事件多快你就多快画——高轮询率的鼠标比屏幕刷新还快,而用户手停下来时你又什么都不画了。

更好的形状是把两者分开。处理器只做一件事:记录位置。渲染循环去读这条记录:

现在帧率归你管,记录下来的位置可以被插值、平滑、衰减,或者被五个效果同时读。它还顺带解决了「指针不动就卡住」的问题:循环照跑,拖尾照淡,那正是它看起来「活着」的原因。

这也是 pointermove 比 mousemove 好的原因。它把鼠标、手写笔、触摸统一成一个事件,还给你 pointerType,于是行为可以分岔——跟着手指的拖尾,必须比跟着鼠标的宽容得多。

先记录,再渲染
let px = 0, py = 0, hasPointer = false;

canvas.addEventListener('pointermove', e => {
  px = e.offsetX; py = e.offsetY; hasPointer = true;   // 只记录
});

function frame() {
  if (hasPointer) spawn(px, py);      // 渲染读记录
  drawAndDecay();
  requestAnimationFrame(frame);
}

拖尾是一条带死亡时钟的缓冲

拖尾不是一根线。它是一串过去的位置,每个带一个剩余生命,被画成一个个点:

两个细节决定它顺不顺。第一,衰减必须按时间算,不能按帧算——否则拖尾长度会随刷新率变化,144Hz 屏幕上和 60Hz 上长得不一样。第二,生成速率和衰减速率是两回事:生成慢 + 衰减慢 = 虚线;生成快 + 衰减快 = 紧凑的彗星。

霓虹笔是同一个缓冲换了个画法。不是画点,而是把相邻点连成路径段,每段独立淡出。于是线从尾部溶解、头部保持明亮——这是「有热度的东西在移动」的视觉签名。

笔这里有一个必须处理的细节:指针离开再回来时,路径要断开。否则左边画一笔、右边画一笔,缓冲不知道笔被抬起来过,会给你连出一条长长的对角线。

带生命的坐标缓冲
const pts = [];
function spawn(x, y) {
  pts.push({ x, y, l: 1 });
}
function drawAndDecay() {
  for (let i = pts.length - 1; i >= 0; i--) {
    const p = pts[i];
    p.l -= 0.02;
    if (p.l <= 0) { pts.splice(i, 1); continue; }
    ctx.globalAlpha = p.l;
    ctx.beginPath();
    ctx.arc(p.x, p.y, 6 * p.l, 0, 7);
    ctx.fill();
  }
}

眼睛是一次减法加一次钳制

跟随的眼睛是本站最便宜也最有效的效果,因为它利用了一个很古老的反射。瞳孔就是眼睛中心沿指针方向偏移出去的一点:

钳制才是全部诀窍。没有它,指针一远,瞳孔就滑出眼眶。有了它,瞳孔停在边缘不动——而停在边缘这个位置,才读作「在看」,而不是「在指」。

归一化也一样重要:除以真实距离,让偏移变成单位向量,再乘最大半径。这和力场那篇里的力归一化是同一个操作,只是用途完全不同。

一排眼睛让人不安而不是觉得可爱,原因在时间上:所有眼睛在同一瞬间到达钳制位置,于是整排像一个生物在动。给每只眼睛加一点点延迟,它就变友好了。这是一个设计杠杆,不是 bug。

瞳孔偏移,带钳制
const dx = px - eyeX, dy = py - eyeY;
const d  = Math.hypot(dx, dy) || 1;
const maxOffset = 14;                          // 眼白半径 - 瞳孔半径
pupilX = eyeX + dx / d * Math.min(d, maxOffset);
pupilY = eyeY + dy / d * Math.min(d, maxOffset);

星座连线:近的就连,但要给成本设上限

星座在每一对距离小于阈值的节点之间画线,透明度随距离衰减:

这是 O(n²),六十个节点完全没问题——每帧 1770 次配对检查,什么都不是。一千个节点就不行了,那需要空间网格;示例刻意把数量压着,而不是假装它能扩展。

距离衰减才是它像星座而不是像网格的原因。用硬阈值,线会在临界点上「啪啪」地出现和消失。用 1 - d/maxDist,线在两个节点刚好到达极限时正好淡到零——过渡不可见,于是这张图看起来是在有机地生长和收缩。

一个容易漏的性能点:每根线设一次 strokeStyle,就是每次绘制一次状态切换。把线按透明度分成八档,样式只设八次而不是一千次,视觉上看不出差别,性能上差很远。

带衰减的两两连线
for (let i = 0; i < nodes.length; i++) {
  for (let j = i + 1; j < nodes.length; j++) {
    const a = nodes[i], b = nodes[j];
    const d = Math.hypot(a.x - b.x, a.y - b.y);
    if (d > maxDist) continue;
    ctx.globalAlpha = 1 - d / maxDist;       // 淡出,不要突然出现
    ctx.beginPath();
    ctx.moveTo(a.x, a.y);
    ctx.lineTo(b.x, b.y);
    ctx.stroke();
  }
}

水波:几圈错开的环,不是一个长大的圆

水波看起来是一个圆在扩散。其实是好几个圆,同时生成,但起始时间和寿命各不相同:

只画一个不断变大的圆,你得到的是准星。真实感来自错开——三四圈相位略有不同、各自边扩散边淡出,于是这次扰动看起来有持续时间、有尾流。

另一个配料是扩张不均匀。真实的水波起步快、后段慢。r = maxR * Math.sqrt(t) 恰好给出这种缓动,成本是一次函数调用——就是这一处改动,让一个扩散的圆不再像 UI 动画。

错开的环 + 开方缓动
function ripple(cx, cy) {
  for (let i = 0; i < 4; i++) {
    waves.push({ x: cx, y: cy, t: -i * 0.12, max: 90 + i * 26 });
  }
}
// 每帧
waves.forEach(w => {
  w.t += 0.02;
  if (w.t < 0) return;                       // 还没开始
  const r = w.max * Math.sqrt(w.t);          // 先快后慢
  ctx.globalAlpha = Math.max(0, 1 - w.t);
  ctx.beginPath();
  ctx.arc(w.x, w.y, r, 0, 7);
  ctx.stroke();
});

接下来可以怎么用

存下指针的位置,别在事件里动手。一旦输入和渲染分开,五个不同的效果就能读同一条流——而每一个说的都是「衰减」:一个会清空的缓冲、一个被钳制的偏移、一条会淡的线、一个会慢下来的环。

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 个特效