GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Fingerprint Scan Animation: 9 Biometric UI Demos

Nine fingerprint scan animation patterns: one lights ridges by scan-progress value, one shakes by spring-damping value on failure, one grows ripple radius by

Auto-plays · click a tile to jump to its section · all nine in one zip

None of the nine call a real fingerprint or face API — every one is a simulation driven by a single numeric value the visitor never sees directly: a scan-progress fraction, a decay age, a shake offset, a morph blend, a press pressure, a noise sample, a spring position, a diagonal delay, or a breathing phase. That single-value scope is exactly why picking the right biometric scan animation rarely means reading nine different codebases — once you know which value a scene answers, the code underneath just confirms the mechanism.

Anatomy of nine patterns — which value reacts to auth state

Item 01's apply() turns a scan-line's scanY position into a per-ridge activation ratio, (scanY - apex) / GLOW_BAND, so each ridge's opacity rises only after the line has passed its own apex; item 02's tick() ages every open ring slot and shrinks its radius toward PULSE_MAX_R under 1 - Math.exp(-age / TAU), an exponential approach rather than a linear one; item 03's apply() multiplies a decaying exponential by a cosine, AMPLITUDE * Math.exp(-t / decayMs) * Math.cos(t * FREQ), so the shake offset itself is the damped-oscillation formula rather than a CSS keyframe standing in for one; item 04's buildPath() lerps ten points between circlePoint() positions and a hand-placed CHECK_PTS array by a single progress value, redrawing the SVG d string every frame; item 05's buildPath() samples a sine ring, BASE_R + AMPLITUDE * pressure * Math.sin(WAVE_K * theta - t * WAVE_SPEED), where pressure comes straight from PointerEvent.pressure during a real press; item 06's noise() sums three weighted, phase-shifted sine waves rather than sampling any noise field, so its "flicker" is a deterministic sum rather than a sampled one; item 07's autoLoop() integrates a spring each frame, force = (target - autoPos) * STIFFNESS - autoVel * DAMPING, and flips target between two values every FLIP_MS so the ring never fully settles; item 08's render() computes each grid cell's own delay from its row-plus-column index, delay = cells[i].diag * DIAG_STEP, before feeding that into a Math.sin pulse window; and item 09's render() drives a single scale = 1 + AMPLITUDE * Math.sin(t * OMEGA) while offsetting each ridge's own brightness by RIDGE_PHASE_STEP so the glow appears to travel outward. Eight of the nine — every one but item 05 — listen for click and keydown to detect a real visitor; item 05 keeps keydown too but swaps click for pointerdown, pointermove, and pointerup, since it needs a continuous pressure value rather than a single press. In every scene, the first real interaction calls human(), which strips the .is-demo class — in the five trigger-based scenes (01, 03, 04, 05, 07) that shuts off the periodic demo trigger gated by isDemo && !reduce for good, while the four ambient scenes (02, 06, 08, 09) keep looping exactly as before and only start or stop through their own toggle state. Every driver — the autoplay trigger and each scene's own per-frame math alike — runs through requestAnimationFrame, and Math.random appears in none of the nine files. Each of the nine also carries its own prefers-reduced-motion: reduce branch in JS (checked once via matchMedia) paired with a transition: none rule in its Sass, though what that branch actually does — skip a timer, compress a duration, or paint one static frame — differs by item, as the sections below show.

Code per pattern

01Scan-line sweep — a scan-progress value moves top to bottom while the ridge-brightness value at each passed line rises in order

A scan-progress value climbs from the top of the icon to the bottom over BASE_MS / SWEEP_SPEED milliseconds, and the moment that value passes a ridge's own apex, the ridge's brightness value starts rising toward full opacity within a GLOW_BAND distance. Because each ridge checks its own apex independently, the higher ridges finish brightening before the lower ones even start, which is what reads as ridges lighting up in order rather than the whole icon flashing at once — a fit for a login screen's biometric-wait button or an app's lock screen.

Scan-line sweep — a scan-progress value moves top to bottom while the ridge-brightness value at each passed line rises in order
Open in a new tab

01 · Scan-line sweep — a scan-progress value moves top to bottom while the ridge-brightness value at each passed line rises in order — Measured area 12.571% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

BASE_MS$subject-blue-lightGLOW_BAND
  function apply(scanY) {
    beam.setAttribute('y1', scanY.toFixed(2));
    beam.setAttribute('y2', scanY.toFixed(2));
    for (var i = 0; i < ridges.length; i++) {
      var apex = parseFloat(ridges[i].getAttribute('data-apex'));
      var act = Math.max(0, Math.min(1, (scanY - apex) / GLOW_BAND + 0.001));
      ridges[i].style.opacity = (0.26 + 0.74 * act).toFixed(3);
    }
  }

What the numbers say

Item 01 fills 12.57% of the frame cumulatively at a 63.8 average intensity while moving in only 12 of 23 sampled frames — the highest cumulative area among the nine but the fewest moving frames, because the sweep only runs during its own trigger cycle and sits fully still through most of the GAP_MS pause between cycles, yet every frame it does paint covers the beam line and up to seven ridges at once.

02Idle pulse — while waiting for authentication, a circular pulse's radius value grows under a decay function and fades as it grows

Every LAUNCH_GAP milliseconds a new ring slot opens, and from that moment its radius value grows toward PULSE_MAX_R under an exponential-decay curve rather than a straight line, so growth is fast at first and slows as it nears the maximum. As the radius value climbs, the same slot's opacity value falls in the opposite direction, so a ring is brightest the instant it spawns and all but invisible once it reaches PULSE_MAX_R — a fit for a biometric-wait screen or a payment-approval wait state.

Idle pulse — while waiting for authentication, a circular pulse's radius value grows under a decay function and fades as it grows
Open in a new tab

02 · Idle pulse — while waiting for authentication, a circular pulse's radius value grows under a decay function and fades as it grows — Measured area 3.397% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

LAUNCH_GAP$subject-blue-deepPULSE_MAX_R
  function tick(now) {
    for (var i = 0; i < slots.length; i++) {
      var slot = slots[i];
      if (slot.start === null) { continue; }
      var age = now - slot.start;
      if (age > CUTOFF) {
        slot.start = null;
        slot.el.style.opacity = '0';
        continue;
      }
      var radius = PULSE_MAX_R * (1 - Math.exp(-age / TAU));
      var opacity = Math.max(0, 1 - radius / PULSE_MAX_R);
      slot.el.setAttribute('r', radius.toFixed(2));
      slot.el.style.opacity = opacity.toFixed(3);
    }
  }

What the numbers say

Item 02 fills 3.40% of the frame cumulatively at a 36.1 average intensity while moving in 19 of 23 sampled frames, because only four ring slots ever exist at once and each one fades toward zero opacity as it grows, so the painted area stays small even though the loop itself never stops.

03Fail-shake decay — on a failed match, the icon shakes left-right along a spring-damping value that settles to zero

The instant a match fails, a shake-offset value starts at its full amplitude and decays under AMPLITUDE * Math.exp(-t / decayMs) * Math.cos(t * FREQ), so the icon swings hardest in the first fraction of a second and settles smoothly to zero rather than stopping abruptly. SHAKE_DAMP sets decayMs directly (decayMs = SHAKE_DAMP * 40), so a lower damping value shrinks that time and the shake reaches zero sooner rather than later — a fit for biometric-failure feedback or an unlock-failure screen.

Fail-shake decay — on a failed match, the icon shakes left-right along a spring-damping value that settles to zero
Open in a new tab

03 · Fail-shake decay — on a failed match, the icon shakes left-right along a spring-damping value that settles to zero — Measured area 5.362% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

SETTLE_MS$subject-creamSHAKE_DAMP
  function apply(t) {
    var offset = AMPLITUDE * Math.exp(-t / decayMs) * Math.cos(t * FREQ);
    icon.style.transform = 'translateX(' + offset.toFixed(2) + 'px)';
  }

  function runShake(ts) {
    if (!startTs) startTs = ts;
    var t = ts - startTs;
    if (t <= SETTLE_MS) {
      apply(t);
      requestAnimationFrame(runShake);
    } else {
      apply(0);
      status.textContent = STRINGS.idle;
      running = false;
      root.setAttribute('aria-pressed', 'false');
    }
  }

What the numbers say

Item 03 fills 5.36% of the frame cumulatively at a 132.2 average intensity while moving in 16 of 23 sampled frames, because the icon only redraws while t <= SETTLE_MS — once the offset decays close enough to zero the loop stops entirely, so the painted area is concentrated in the early, wide swings rather than spread evenly across the whole cycle.

04Success morph — on a successful match, the fingerprint icon's path value interpolates smoothly into a checkmark path value

The instant a match succeeds, an interpolation value climbs from 0 to 1 over MORPH_MS, and buildPath() lerps all ten path points between their fingerprint-circle position and a hand-placed CHECK_PTS coordinate by that same value, so the SVG d string is rebuilt fresh every frame rather than transitioning between two fixed shapes. A fit for a success micro-interaction or an app-unlock confirmation.

Success morph — on a successful match, the fingerprint icon's path value interpolates smoothly into a checkmark path value
Open in a new tab

04 · Success morph — on a successful match, the fingerprint icon's path value interpolates smoothly into a checkmark path value — Measured area 3.191% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

MORPH_MS$subject-blue-deepCHECK_PTS
  function buildPath(p) {
    var d = '';
    for (var i = 0; i < N; i++) {
      var c = circlePoint(i);
      var k = CHECK_PTS[i];
      var x = lerp(c[0], k[0], p);
      var y = lerp(c[1], k[1], p);
      d += (i === 0 ? 'M' : 'L') + x.toFixed(2) + ',' + y.toFixed(2) + ' ';
    }
    return d + 'Z';
  }

What the numbers say

Item 04 has the highest average intensity among the nine, 158.6, while filling 3.19% of the frame cumulatively and moving in only 15 of 23 sampled frames, because rebuilding all ten path points every frame changes sharp path edges by a large amount per pixel even though the total morph only plays twice per trigger cycle (fingerprint-to-check, then back).

05Pressure ripple — as a press-pressure value rises, a concentric ripple's radius and amplitude values grow together

While a visitor's finger or pointer is down, a pressure value read straight from PointerEvent.pressure sets how far a ring of thirty-two points swings from its base radius, BASE_R + AMPLITUDE * pressure * Math.sin(WAVE_K * theta - t * WAVE_SPEED), so a light touch barely ripples while a firm press sends the wave out further. When no real pointer is down, the same pressure input comes from an automatic sine cycle instead — a fit for a fingerprint-sensor interaction demo or a touch-pressure feedback background.

Pressure ripple — as a press-pressure value rises, a concentric ripple's radius and amplitude values grow together
Open in a new tab

05 · Pressure ripple — as a press-pressure value rises, a concentric ripple's radius and amplitude values grow together — Measured area 3.583% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

PRESS_CYCLE_MS$subject-blue-lightWAVE_K
  function buildPath(t, pressure) {
    var d = '';
    for (var i = 0; i <= M; i++) {
      var theta = (i / M) * Math.PI * 2;
      var r = BASE_R + AMPLITUDE * pressure * Math.sin(WAVE_K * theta - t * WAVE_SPEED);
      var x = 60 + r * Math.cos(theta);
      var y = 60 + r * Math.sin(theta);
      d += (i === 0 ? 'M' : 'L') + x.toFixed(2) + ',' + y.toFixed(2) + ' ';
    }
    return d + 'Z';
  }

What the numbers say

Item 05 fills 3.58% of the frame cumulatively at a 107.7 average intensity while moving in 19 of 23 sampled frames, because the thirty-two-point wave redraws every frame regardless of pressure, but the wave's own amplitude — and so the area it actually disturbs — depends on how large the current pressure value is.

06Hologram flicker — a 2D noise value drives a subtle flicker-intensity value on the scan icon

Three sine waves at different, near-incommensurate frequencies (FREQS) get weighted and summed into a single value between roughly -1 and 1, and that value maps onto each element's opacity — the glyph gets one phase offset, and each of the four scan lines gets its own offset by LINE_PHASE_STEP, so no two lines brighten and dim in lockstep. This sum is deterministic rather than a sampled noise field, and Math.random never runs — a fit for a biometric-wait icon or a sci-fi-styled authentication screen.

Hologram flicker — a 2D noise value drives a subtle flicker-intensity value on the scan icon
Open in a new tab

06 · Hologram flicker — a 2D noise value drives a subtle flicker-intensity value on the scan icon — Measured area 3.044% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

LINE_PHASE_STEP$subject-skyFREQS
  function noise(t, phaseOffset) {
    var v = 0;
    for (var i = 0; i < 3; i++) {
      v += WEIGHTS[i] * Math.sin(t * FREQS[i] + PHASES[i] + phaseOffset);
    }
    return v / WSUM;
  }

What the numbers say

Item 06 fills 3.04% of the frame cumulatively at only a 38.8 average intensity while moving in 22 of 23 sampled frames — nearly every sampled frame moves, because the three-sine sum never fully levels off, but each individual opacity step is small, so the per-pixel intensity stays low, second only to item 09's.

07Ring overshoot — a circular progress ring fills by an angle value, then overshoots by an inertia value at completion before settling back

A stroke-dashoffset value tracks a spring physics simulation, force = (target - pos) * STIFFNESS - vel * DAMPING, rather than an eased tween, so when the target shifts the ring's fill genuinely overshoots past it before inertia pulls it back. The automatic demo alternates target between two match-rate values every FLIP_MS, a shorter span than the spring's own settle time, so the ring is always mid-swing rather than resting at either extreme.

Ring overshoot — a circular progress ring fills by an angle value, then overshoots by an inertia value at completion before settling back
Open in a new tab

07 · Ring overshoot — a circular progress ring fills by an angle value, then overshoots by an inertia value at completion before settling back — Measured area 1.799% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

FLIP_MS$subject-blue-deepSTIFFNESS
  function autoLoop(ts) {
    if (!isDemo || reduce) return;
    if (!autoLastTs) autoLastTs = ts;
    var dt = Math.min(ts - autoLastTs, 32);
    autoLastTs = ts;
    if (!autoFlipAt) autoFlipAt = ts + FLIP_MS;
    if (ts >= autoFlipAt) { autoTargetHigh = !autoTargetHigh; autoFlipAt = ts + FLIP_MS; }
    var target = autoTargetHigh ? AUTO_HIGH : AUTO_LOW;
    var force = (target - autoPos) * STIFFNESS - autoVel * DAMPING;
    autoVel += force * dt;
    autoPos += autoVel * dt;
    paint(autoPos);
    status.textContent = STRINGS.running;
    rafId = requestAnimationFrame(autoLoop);
  }

What the numbers say

Item 07 has the lowest cumulative area among the nine, 1.80%, yet the most moving frames, 23 of 23, at a 136.8 average intensity, because the spring never actually settles under the auto-loop's short flip interval — it keeps nudging the thin stroke and the percentage text by a small amount on almost every sampled frame instead of resting between larger, rarer jumps.

08Grid-scan glow — the scan area is split into a grid, and each cell glows in turn on its own delay value

Each of the thirty-six grid cells carries a diag value equal to its row plus its column, and render() turns that into a per-cell delay, diag * DIAG_STEP, before feeding the delayed local time into a Math.sin pulse that lights the cell and scales it slightly larger at its peak. Because delay only depends on row-plus-column, cells along the same diagonal always glow together, which is what reads as light sweeping corner to corner — a fit for a face-or-fingerprint grid-scan effect or a security-authentication loading screen.

Grid-scan glow — the scan area is split into a grid, and each cell glows in turn on its own delay value
Open in a new tab

08 · Grid-scan glow — the scan area is split into a grid, and each cell glows in turn on its own delay value — Measured area 12.0% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

DIAG_STEP$subject-mintCYCLE_MS
  function render(t) {
    for (var i = 0; i < cells.length; i++) {
      var delay = cells[i].diag * DIAG_STEP;
      var local = ((t - delay) % CYCLE_MS + CYCLE_MS) % CYCLE_MS;
      var v = pulse(local);
      cells[i].el.style.opacity = (0.16 + v * 0.84).toFixed(3);
      cells[i].el.style.transform = 'scale(' + (1 + v * 0.22).toFixed(3) + ')';
    }
  }

What the numbers say

Item 08 fills 12.00% of the frame cumulatively at a 76.7 average intensity while moving in 20 of 23 sampled frames — nearly tied with item 01 for the highest cumulative area — because thirty-six cells pulse continuously on a repeating cycle rather than only during a short trigger window, so a wide diagonal band of the grid is almost always mid-glow.

09Idle breathing — with no input, the icon's scale value rises and falls along a sine-easing curve on repeat

With no scan or press in progress, a single scale value swings between roughly 0.91 and 1.10 under 1 + AMPLITUDE * Math.sin(t * OMEGA), applied to the whole icon at once, while each of the seven ridges also brightens and dims on its own sine curve offset by RIDGE_PHASE_STEP from its neighbors. Because the ridge phases step outward in order, the brightness itself appears to travel from the center ridge to the outer ones even though every ridge runs the exact same formula — a fit for a biometric-button idle state or a lock-screen icon.

Idle breathing — with no input, the icon's scale value rises and falls along a sine-easing curve on repeat
Open in a new tab

09 · Idle breathing — with no input, the icon's scale value rises and falls along a sine-easing curve on repeat — Measured area 3.988% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

RIDGE_PHASE_STEP$subject-lilacOMEGA
  function render(t) {
    var s = 1 + AMPLITUDE * Math.sin(t * OMEGA);
    icon.style.transform = 'scale(' + s.toFixed(4) + ')';
    for (var i = 0; i < ridges.length; i++) {
      var phase = t * OMEGA + i * RIDGE_PHASE_STEP;
      var b = 0.5 + 0.5 * Math.sin(phase);
      ridges[i].style.opacity = b.toFixed(3);
    }
  }

What the numbers say

Item 09 fills 3.99% of the frame cumulatively at the lowest average intensity among the nine, 23.9, while moving in 21 of 23 sampled frames, because the breathing scale never stops but its swing is small — a 9% size change and a soft ridge fade rather than a hard repaint — so almost every frame moves a little without any single frame moving much.

Where values change shake and brightness — progress, pressure, and damping basis

A biometric login ui usually means one spinner standing in for "please wait," yet three specific items here answer three completely different bases. Item 01's ridge brightness answers a scan-progress value directly: the beam's scanY position is the only input apply() reads, so the ridges brighten strictly in the order the beam passes them, regardless of how long the whole sweep takes. Item 05's ripple amplitude answers a press-pressure value instead: PointerEvent.pressure sets how far the ring swings on every frame, so a light touch and a firm press redraw the same thirty-two points but by very different amounts. Item 03's shake offset, by contrast, answers a damping value on its own: SHAKE_DAMP decides only how long the oscillation takes to reach zero, not how far it swings at the start, so a heavier device or a lighter one produces the same peak amplitude with a different tail length. The zip below carries all nine scenes together behind one password, xysfva3x, so there is no separate unlock to look up per item. These three constants keep answering their own basis no matter which scene they sit in — item 01's brightness still answers position, item 05's ripple still answers pressure, item 03's decay still answers damping — so only the surrounding numbers change.

That progress-versus-pressure-versus-damping split matters most once a biometric scan animation like this gets adapted into a real form. 9 Login UI Design Patterns (Copy-Paste) moves a visitor through a whole flow — signup, login, session expiry — with each screen answering a step index rather than any single physics value the way these nine scenes do frame by frame. 9 ID Verification Screen UI Patterns, Paste-Ready lays out a full verification screen's static structure rather than a single button's continuous feedback value. Too Many Failed Login Attempts: 9 Patterns counts a discrete attempts-remaining value down to zero, a stepped integer rather than the continuous decay or spring values these nine biometric scenes read every frame.

Where it breaks — rapid retries, low-end devices, reduced motion

A biometric scan animation simulation like this behaves differently once real timing and real hardware get involved, and three spots show it clearly.

Retrying while the fail-shake is still settling

Item 03's trigger() opens with if (running) return; — a second failure that arrives while the shake from the first one hasn't finished yet is silently dropped rather than restarting the animation. A visitor who taps again immediately after a failed scan sees the same shake finish its own decay; the second failure exists in application state but never gets its own visual moment.

Spring physics under dropped frames

Item 07's autoLoop() clamps its own delta time to 32 milliseconds, Math.min(ts - autoLastTs, 32), so a frame that actually took 100 milliseconds on a slow device still advances the spring as if only 32 had passed. Because FLIP_MS keeps swapping the target every 480 milliseconds regardless of how the spring is doing, heavy frame drops mean the ring never catches up to either target before the next flip arrives.

Reduced motion compresses the sweep instead of skipping it

Item 01 sets DUR = reduce ? 1 : (BASE_MS / SWEEP_SPEED), so prefers-reduced-motion: reduce doesn't remove the click-triggered sweep — it collapses the whole top-to-bottom pass into a single one-millisecond frame. A visitor who explicitly asks for less motion and then clicks the button still sees a near-instant flash of every ridge lighting rather than the animation being skipped outright.

Three variants and accessibility

Lowering the sweep speed from 1.4 to 0.6 makes the scan-progress value climb slowly, so the in-order ridge brightening reads far more clearly. Lowering the shake damping from 18 to 8 shrinks decayMs from 720 ms to 320 ms, so the offset decays to zero sooner and the failure shake snaps once or twice before stopping rather than ringing on. Raising the idle-pulse max radius from 40 to 70 lets the pulse spread wider before fading, so the waiting state feels more relaxed.

Variant What changes What it doesn't touch
Slower scan sweep SWEEP_SPEED 1.4 → 0.6 GLOW_BAND, BASE_MS, the ridge-apex layout itself
Faster fail-shake settle SHAKE_DAMP 18 → 8 AMPLITUDE, FREQ, SETTLE_MS
Wider idle pulse PULSE_MAX_R 40 → 70 TAU, LAUNCH_GAP, the ring-slot cursor

Every one of the nine marks its own SVG icon aria-hidden="true", since the icon is decorative feedback rather than information on its own, and pairs it with a visible status paragraph (Idle, Scanning, Auth failed, Auth success, and so on) that updates as plain text next to the icon — there's no aria-live region on it, so that text change alone isn't announced to a screen reader the way the button's own aria-pressed state is. Each toggleable button carries aria-pressed, and every one but item 05 listens for keydown on Space or Enter alongside click, so the same fail-shake, success-morph, or sweep a mouse user triggers is also reachable from a keyboard.

FAQ

Does a biometric verification ui like this one need a real fingerprint sensor to run?

No — every one of the nine is a simulation. There is no fingerprint or face API call anywhere in the nine files; scan progress, pressure, and match rate are all plain numbers driven by a click, a keypress, or PointerEvent.pressure, and the zip you unlock contains the exact same numeric-value code shown above.

Is this fingerprint scan animation set nine separate files, or one component with different settings?

Nine independent files — each item keeps its own constants and its own step or render function, and the fence shown above a demo is a direct excerpt from that same file rather than the output of a shared generator swapping numbers into one template.

Why does item 06's hologram flicker not use an actual noise library?

Because it doesn't need one — three sine waves at different frequencies, weighted and summed in noise(), already produce a value that never repeats on a human timescale, and skipping an external noise library keeps the file self-contained the way every other item in this set is.

Sources: MDN — CSS animations · MDN — Web Animations API · MDN — stroke-dashoffset · MDN — Window.requestAnimationFrame()

Enter the archive password

The password is inside this article. You will find it as you read.