Made the interactions feel snappier and a little more playful. Tiny detail, but it makes the whole experience feel faster and more responsive.
1. The goo filter: what makes the cells fuse
A blur, then a hard alpha threshold. Shapes that are close blur into each other, and the threshold turns the overlap into one solid edge:
<filter id={id} x="-50%" y="-50%" width="200%" height="200%" colorInterpolationFilters="sRGB">
<feGaussianBlur in="SourceGraphic" stdDeviation={blur} result="smear" />
{}
<feColorMatrix in="smear" type="matrix"
values={`1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 ${cut} ${-(cut / 2)}`} />
</filter>
const { url, goo } = useGoo(3, 24) <filter id={id} x="-50%" y="-50%" width="200%" height="200%" colorInterpolationFilters="sRGB">
<feGaussianBlur in="SourceGraphic" stdDeviation={blur} result="smear" />
{}
<feColorMatrix in="smear" type="matrix"
values={`1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 ${cut} ${-(cut / 2)}`} />
</filter>
const { url, goo } = useGoo(3, 24) <filter id={id} x="-50%" y="-50%" width="200%" height="200%" colorInterpolationFilters="sRGB">
<feGaussianBlur in="SourceGraphic" stdDeviation={blur} result="smear" />
{}
<feColorMatrix in="smear" type="matrix"
values={`1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 ${cut} ${-(cut / 2)}`} />
</filter>
const { url, goo } = useGoo(3, 24) The cells and the capsule sit under this filter, and the digits sit outside it. The threshold would eat the edges of text, which is why the digits are kept out.
2. One spring loop that stops itself
Every moving value is integrated per frame and the loop stops once things settle. dt is counted in 60Hz frames, and the damping is raised to the power dt rather than multiplied by it, so a dropped frame doesn’t change how the spring feels:
const MERGE = { k: 0.075, d: 1 - 2 * 0.42 * Math.sqrt(0.075) }
mg.v += (mg.to - mg.m) * MERGE.k * dt
mg.v *= Math.pow(MERGE.d, dt)
mg.m += mg.v * dt
raf.current = busy ? requestAnimationFrame(tick) : 0 const MERGE = { k: 0.075, d: 1 - 2 * 0.42 * Math.sqrt(0.075) }
mg.v += (mg.to - mg.m) * MERGE.k * dt
mg.v *= Math.pow(MERGE.d, dt)
mg.m += mg.v * dt
raf.current = busy ? requestAnimationFrame(tick) : 0 const MERGE = { k: 0.075, d: 1 - 2 * 0.42 * Math.sqrt(0.075) }
mg.v += (mg.to - mg.m) * MERGE.k * dt
mg.v *= Math.pow(MERGE.d, dt)
mg.m += mg.v * dt
raf.current = busy ? requestAnimationFrame(tick) : 0 The caret uses the same pattern with a stiffer, more damped spring (0.2, 0.62).
3. The merge: cells slide to the centre while a capsule grows
m is the merge spring. It is clamped to 0–1 for the cells, and its overshoot past 1 is given only to the capsule, as a stretch:
const m = clamp(raw, 0, 1)
const cellX = (i) => mix(home(i), mid, m) + shake(i)
const cellR = mix(r, H / 2, smooth(0.15, 0.7, m))
const over = raw - 1
const capW = CAP * smooth(0.3, 1, m) * (1 + Math.max(-0.12, over) * 0.9)
const capSy = 1 - Math.max(-0.12, over) * 0.55
const m = clamp(raw, 0, 1)
const cellX = (i) => mix(home(i), mid, m) + shake(i)
const cellR = mix(r, H / 2, smooth(0.15, 0.7, m))
const over = raw - 1
const capW = CAP * smooth(0.3, 1, m) * (1 + Math.max(-0.12, over) * 0.9)
const capSy = 1 - Math.max(-0.12, over) * 0.55
const m = clamp(raw, 0, 1)
const cellX = (i) => mix(home(i), mid, m) + shake(i)
const cellR = mix(r, H / 2, smooth(0.15, 0.7, m))
const over = raw - 1
const capW = CAP * smooth(0.3, 1, m) * (1 + Math.max(-0.12, over) * 0.9)
const capSy = 1 - Math.max(-0.12, over) * 0.55
The fusing itself is never drawn. The cells get close enough and the filter joins them.
4. The small motions
1 + 0.04 * Math.sin(Math.PI * u)
const u = ((now % 900) / 900) * (n + 2) - i
1 - 0.07 * (u > 0 && u < 2 ? Math.sin((u / 2) * Math.PI) : 0)
5 * Math.exp(-since / 190) * Math.sin((since / 1000) * Math.PI * 2 * 10 + i * 0.55)
smooth(0, 1, (since - 300 - (n - 1 - i) * 45) / 220)
1 + 0.04 * Math.sin(Math.PI * u)
const u = ((now % 900) / 900) * (n + 2) - i
1 - 0.07 * (u > 0 && u < 2 ? Math.sin((u / 2) * Math.PI) : 0)
5 * Math.exp(-since / 190) * Math.sin((since / 1000) * Math.PI * 2 * 10 + i * 0.55)
smooth(0, 1, (since - 300 - (n - 1 - i) * 45) / 220)
1 + 0.04 * Math.sin(Math.PI * u)
const u = ((now % 900) / 900) * (n + 2) - i
1 - 0.07 * (u > 0 && u < 2 ? Math.sin((u / 2) * Math.PI) : 0)
5 * Math.exp(-since / 190) * Math.sin((since / 1000) * Math.PI * 2 * 10 + i * 0.55)
smooth(0, 1, (since - 300 - (n - 1 - i) * 45) / 220)
5. Digits rise in (CSS)
Using backwards fill so the drain’s inline opacity still applies afterwards:
.otp-digit { animation: otp-in 300ms cubic-bezier(0.22, 1, 0.36, 1) backwards; }
@keyframes otp-in {
from { transform: translateY(45%) scale(0.86); opacity: 0; filter: blur(3px); }
to { transform: none; opacity: 1; filter: none; }
}.otp-digit { animation: otp-in 300ms cubic-bezier(0.22, 1, 0.36, 1) backwards; }
@keyframes otp-in {
from { transform: translateY(45%) scale(0.86); opacity: 0; filter: blur(3px); }
to { transform: none; opacity: 1; filter: none; }
}.otp-digit { animation: otp-in 300ms cubic-bezier(0.22, 1, 0.36, 1) backwards; }
@keyframes otp-in {
from { transform: translateY(45%) scale(0.86); opacity: 0; filter: blur(3px); }
to { transform: none; opacity: 1; filter: none; }
}All of it is skipped under prefers-reduced-motion. The springs jump straight to their targets and the CSS animations are turned off.
Used a component from bencho.dev for the interaction. Video with made using skill by finkoegor.