Snappy OTP code field

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" />
  {/* alpha × cut − cut/2: under 0.5 → gone, over ~0.54 → solid */}
  <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)   // blur 3px, cut 24
<filter id={id} x="-50%" y="-50%" width="200%" height="200%" colorInterpolationFilters="sRGB">
  <feGaussianBlur in="SourceGraphic" stdDeviation={blur} result="smear" />
  {/* alpha × cut − cut/2: under 0.5 → gone, over ~0.54 → solid */}
  <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)   // blur 3px, cut 24
<filter id={id} x="-50%" y="-50%" width="200%" height="200%" colorInterpolationFilters="sRGB">
  <feGaussianBlur in="SourceGraphic" stdDeviation={blur} result="smear" />
  {/* alpha × cut − cut/2: under 0.5 → gone, over ~0.54 → solid */}
  <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)   // blur 3px, cut 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) } // just under critical damping

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   // park when nothing moves
const MERGE = { k: 0.075, d: 1 - 2 * 0.42 * Math.sqrt(0.075) } // just under critical damping

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   // park when nothing moves
const MERGE = { k: 0.075, d: 1 - 2 * 0.42 * Math.sqrt(0.075) } // just under critical damping

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   // park when nothing moves

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)          // slide to centre
const cellR = mix(r, H / 2, smooth(0.15, 0.7, m))              // round off into a pill

const over = raw - 1
const capW  = CAP * smooth(0.3, 1, m) * (1 + Math.max(-0.12, over) * 0.9) // long and thin…
const capSy = 1 - Math.max(-0.12, over) * 0.55                            // …then round again
const m = clamp(raw, 0, 1)
const cellX = (i) => mix(home(i), mid, m) + shake(i)          // slide to centre
const cellR = mix(r, H / 2, smooth(0.15, 0.7, m))              // round off into a pill

const over = raw - 1
const capW  = CAP * smooth(0.3, 1, m) * (1 + Math.max(-0.12, over) * 0.9) // long and thin…
const capSy = 1 - Math.max(-0.12, over) * 0.55                            // …then round again
const m = clamp(raw, 0, 1)
const cellX = (i) => mix(home(i), mid, m) + shake(i)          // slide to centre
const cellR = mix(r, H / 2, smooth(0.15, 0.7, m))              // round off into a pill

const over = raw - 1
const capW  = CAP * smooth(0.3, 1, m) * (1 + Math.max(-0.12, over) * 0.9) // long and thin…
const capSy = 1 - Math.max(-0.12, over) * 0.55                            // …then round again

The fusing itself is never drawn. The cells get close enough and the filter joins them.

4. The small motions

// a digit lands: one half-sine swell, 4% at the peak, 260ms
1 + 0.04 * Math.sin(Math.PI * u)

// checking: a wave of breath through the cells, every 900ms
const u = ((now % 900) / 900) * (n + 2) - i
1 - 0.07 * (u > 0 && u < 2 ? Math.sin((u / 2) * Math.PI) : 0)

// wrong code: a decaying 10Hz shiver, each cell slightly out of phase…
5 * Math.exp(-since / 190) * Math.sin((since / 1000) * Math.PI * 2 * 10 + i * 0.55)

// …then the digits drain out right to left, 45ms apart
smooth(0, 1, (since - 300 - (n - 1 - i) * 45) / 220)
// a digit lands: one half-sine swell, 4% at the peak, 260ms
1 + 0.04 * Math.sin(Math.PI * u)

// checking: a wave of breath through the cells, every 900ms
const u = ((now % 900) / 900) * (n + 2) - i
1 - 0.07 * (u > 0 && u < 2 ? Math.sin((u / 2) * Math.PI) : 0)

// wrong code: a decaying 10Hz shiver, each cell slightly out of phase…
5 * Math.exp(-since / 190) * Math.sin((since / 1000) * Math.PI * 2 * 10 + i * 0.55)

// …then the digits drain out right to left, 45ms apart
smooth(0, 1, (since - 300 - (n - 1 - i) * 45) / 220)
// a digit lands: one half-sine swell, 4% at the peak, 260ms
1 + 0.04 * Math.sin(Math.PI * u)

// checking: a wave of breath through the cells, every 900ms
const u = ((now % 900) / 900) * (n + 2) - i
1 - 0.07 * (u > 0 && u < 2 ? Math.sin((u / 2) * Math.PI) : 0)

// wrong code: a decaying 10Hz shiver, each cell slightly out of phase…
5 * Math.exp(-since / 190) * Math.sin((since / 1000) * Math.PI * 2 * 10 + i * 0.55)

// …then the digits drain out right to left, 45ms apart
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.