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

Too Many Failed Login Attempts: 9 Patterns

One value drops by one per failed try, another doubles the wait time as failures pile up, and another counts a remaining-seconds value down to zero until the

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

All nine react to the same trigger — a real click on a button (item 08 has two, fail and success; item 09's listener sits on its whole panel) — and none of them touch a timer or a coin flip to decide what happens next: every number here comes from a fixed formula applied to a counter, never from Math.random(). Nothing here talks to a server, a SMS gateway, or a real CAPTCHA vendor; the "lock," the "captcha," and the "wait time" are all local variables recalculated in the browser, so no claim below should be read as "the server locks the account" — it's this demo's own arithmetic, visible in the tab. Item 01's password field even masks its input with the CSS property -webkit-text-security: disc rather than swapping to type="password", which is why it can still show the shake animation while looking like a password box.

Anatomy of nine patterns — which value changes and when

Four items (01, 03, 04, 07) turn a single counter straight into a rendered value: 01's remaining-tries count drives both a number and a shake amplitude, 03's remaining-tries count drives an SVG ring's stroke-dashoffset, 04's failure count crosses a threshold to pick a captcha cell count from a fixed list, and 07's growing entry list recomputes every row's opacity from its own index. Two items (05, 08) integrate a spring instead of tweening a percentage: both step velocity and position forward each frame from a stiffness (SPRING_K), a damping (SPRING_C), and a mass (SPRING_M), so the banner slide-in and the success-reset bounce settle rather than stop dead. Item 02 and item 06 look similar — a wait value falling toward zero — but 02's wait doubles in length after every new failure while 06's is a fixed lockout length counted straight down by one each second, which is the difference between exponential backoff and a flat cooldown. Item 09 is the odd one out: it decrements the exact same remaining-tries counter on two panels at once, one with a tween and one without, to hold reduced-motion and full-motion side by side on a single click.

Code per pattern

01Remaining-tries shake — the remaining-tries value drops and the number shakes briefly each time login fails

Each failed try drops the remaining-count value by one, and at that instant the number shakes left and right briefly. The shake amplitude itself is a formula too: it grows as remaining tries fall, so the last try before lockout visibly shakes harder than the first.

Remaining-tries shake — the remaining-tries value drops and the number shakes briefly each time login fails
Open in a new tab

01 · Remaining-tries shake — the remaining-tries value drops and the number shakes briefly each time login fails — Measured area 0.505% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

MAX_TRIESAMP_STEPSHAKE_MS
// Calculation layer: amplitude = base + (max - remaining) x step, decaying sine = sin(t x 2pi x frequency) x (1 - t)
function shake() {
  var amp = AMP_BASE + (MAX_TRIES - remaining) * AMP_STEP;
  var start = null;
  shaking = true;
  function frame(ts) {
    if (start === null) start = ts;
    var t = Math.min(1, (ts - start) / SHAKE_MS);
    var x = amp * Math.sin(t * Math.PI * 2 * SHAKE_FREQ) * (1 - t);
    countEl.style.transform = 'translateX(' + x.toFixed(2) + 'px)';
    if (t < 1) requestAnimationFrame(frame);
    else { countEl.style.transform = 'translateX(0)'; shaking = false; }
  }
  requestAnimationFrame(frame);
}

function fail() {
  if (shaking) return;
  remaining = remaining > 0 ? remaining - 1 : MAX_TRIES;
  paint();
  shake();
}

What the numbers say

Measured across 23 tracked frames, item 01 moves only 0.51% of the frame area at a 115.7 intensity score over 14 moving frames — a thin shake confined to a few characters, not a scene.

02Doubling backoff wait — the time value left before the next try doubles with each failure

As the failure count rises, the wait-time value grows to twice its previous value each time. The countdown itself still runs on requestAnimationFrame rather than a per-second tick, which is why the fill bar drains smoothly instead of jumping in whole seconds.

Doubling backoff wait — the time value left before the next try doubles with each failure
Open in a new tab

02 · Doubling backoff wait — the time value left before the next try doubles with each failure — Measured area 9.492% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

BACKOFF_BASE_SECCAP_SECDEMO_TICK_MS
function waitSecondsOf(n) {
  return Math.min(CAP_SEC, BACKOFF_BASE_SEC * Math.pow(2, n));
}

function countdown(totalSec) {
  locked = true;
  btn.disabled = true;
  var start = null;
  var totalMs = totalSec * DEMO_TICK_MS;
  function frame(ts) {
    if (start === null) start = ts;
    var elapsed = ts - start;
    var left = Math.max(0, totalSec - elapsed / DEMO_TICK_MS);
    waitEl.textContent = 'Until next try ' + Math.ceil(left) + 'sec';
    fill.style.transform = 'scaleX(' + Math.max(0, left / totalSec) + ')';
    if (elapsed < totalMs) { rafId = requestAnimationFrame(frame); }
    else {
      waitEl.textContent = 'No wait';
      fill.style.transform = 'scaleX(0)';
      locked = false;
      btn.disabled = false;
    }
  }
  rafId = requestAnimationFrame(frame);
}

What the numbers say

Item 02 covers 9.49% of the frame at a 127.8 intensity across all 23 tracked frames — the fill bar and the wait-time text redraw on nearly every frame while the wait is active.

03Lockout progress ring — the remaining-tries value is shown as a circular progress value

As the remaining-tries value falls, the circular progress value shrinks along with it. The ring's offset is plain circle arithmetic — circumference times the used-up share of the max tries — with no separate percentage variable to keep in sync.

Lockout progress ring — the remaining-tries value is shown as a circular progress value
Open in a new tab

03 · Lockout progress ring — the remaining-tries value is shown as a circular progress value — Measured area 2.46% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

MAX_TRIESRCIRC
// Constants: max try count, ring radius (SVG viewBox coordinates)
var MAX_TRIES = 5;
var R = 40;
var CIRC = 2 * Math.PI * R; // circumference
prog.style.strokeDasharray = CIRC.toFixed(2);

var remaining = MAX_TRIES;

// Calculation layer: ring offset = circumference x (1 - remaining/max) -- stroke-dashoffset arithmetic
function paint() {
  countEl.textContent = String(remaining);
  var offset = CIRC * (1 - remaining / MAX_TRIES);
  prog.style.strokeDashoffset = offset.toFixed(2);
  root.classList.toggle('alr--locked', remaining <= 0);
}

function fail() {
  if (remaining <= 0) { remaining = MAX_TRIES; paint(); return; }
  remaining -= 1;
  paint();
}

What the numbers say

Item 03 sits at 2.46% area and a 150.6 intensity across 22 of 23 frames — most of the change is concentrated in the thin ring stroke rather than the whole card.

04Captcha step-up — the captcha cell-count value grows each time the failure count crosses a threshold value

Each time the failure-count value crosses a set threshold, the captcha cell-count value grows by one step. The step list itself is fixed at four sizes, so the grid can only ever land on one of those cell counts, never something in between.

Captcha step-up — the captcha cell-count value grows each time the failure count crosses a threshold value
Open in a new tab

04 · Captcha step-up — the captcha cell-count value grows each time the failure count crosses a threshold value — Measured area 16.206% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

STAGE_THRESHOLDCELL_STEPSAUTOPLAY_RESET_TICKS
// Constants: failure count that promotes the captcha stage, cell count per stage (must be a perfect square for the grid), autoplay reset interval
var STAGE_THRESHOLD = 2;
var CELL_STEPS = [0, 4, 9, 16]; // stage 0 = none, 1 = 2x2, 2 = 3x3, 3 (cap) = 4x4
var AUTOPLAY_RESET_TICKS = 7;

var fails = 0;

function render() {
  // Calculation layer: stage = min(cap, floor(fails / threshold)), cells = CELL_STEPS[stage]
  var stage = Math.min(CELL_STEPS.length - 1, Math.floor(fails / STAGE_THRESHOLD));
  var cells = CELL_STEPS[stage];
  var cols = Math.round(Math.sqrt(cells)) || 0;
  failsEl.textContent = String(fails);
  stageEl.textContent = stage === 0 ? 'No captcha' : ('Captcha ' + cols + 'x' + cols);
  grid.style.setProperty('--cols', cols || 1);
  grid.innerHTML = '';
  for (var i = 0; i < cells; i++) {
    var c = document.createElement('div');
    c.className = 'ace__cell';
    grid.appendChild(c);
  }
}

What the numbers say

Item 04 measures highest here at 16.21% area and a 142.7 intensity over 18 frames, because a whole grid of cells is created or torn down on the frames where the stage value changes.

05Alert banner slide-in — the banner's position value moves down from off-screen once repeated failures cross a threshold

The moment the repeated-failure count value crosses the threshold, the banner's position value moves from off-screen to on-screen. Position and velocity are integrated every frame from SPRING_K, SPRING_C, and SPRING_M, so the banner overshoots slightly and settles instead of stopping the instant it reaches the target.

Alert banner slide-in — the banner's position value moves down from off-screen once repeated failures cross a threshold
Open in a new tab

05 · Alert banner slide-in — the banner's position value moves down from off-screen once repeated failures cross a threshold — Measured area 8.387% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

THRESHOLDSPRING_KSPRING_C
var SPRING_K = 180;
var SPRING_C = 18;
var SPRING_M = 1;

var fails = 0;
var pos = -60;   // Banner position (%, negative = off-screen above)
var vel = 0;
var target = -60;
var rafId = null;

// Calculation layer: semi-implicit Euler spring integration -- velocity += (-k x displacement - c x velocity) / mass x dt, position += velocity x dt
function tick(ts, last) {
  var dt = Math.min(32, ts - last) / 1000;
  var disp = pos - target;
  var acc = (-SPRING_K * disp - SPRING_C * vel) / SPRING_M;
  vel += acc * dt;
  pos += vel * dt;
  banner.style.transform = 'translateY(' + pos.toFixed(2) + '%)';
  if (Math.abs(disp) > 0.3 || Math.abs(vel) > 0.5) {
    rafId = requestAnimationFrame(function (t) { tick(t, ts); });
  } else {
    pos = target;
    banner.style.transform = 'translateY(' + pos.toFixed(2) + '%)';
  }
}

What the numbers say

Item 05 covers 8.39% of the frame at a 128.2 intensity but over only 12 of 23 frames — the spring settles fast, so the motion is concentrated rather than spread across the clip.

06Unlock countdown — the remaining-seconds value counts down until another try is allowed after a lockout

From the moment of lockout, the remaining-seconds value drops by one each second, and reaching zero switches it back to allowing a try. That per-second drop runs on setInterval, not requestAnimationFrame, since the value only needs to change once per tick — the short pulse that plays on each tick is the part still driven by its own frame loop.

Unlock countdown — the remaining-seconds value counts down until another try is allowed after a lockout
Open in a new tab

06 · Unlock countdown — the remaining-seconds value counts down until another try is allowed after a lockout — Measured area 3.717% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

LOCK_SECONDSDEMO_TICK_MSPULSE_SCALE
function paint() {
  numEl.textContent = String(remaining);
  root.classList.toggle('acc--open', remaining <= 0);
  msgEl.textContent = remaining <= 0 ? 'You can try again now' : 'Seconds left until unlock';
  btn.disabled = remaining > 0;
}

function start() {
  remaining = LOCK_SECONDS;
  paint();
  if (timerId) clearInterval(timerId);
  timerId = setInterval(function () {
    remaining -= 1;
    pulse();
    if (remaining <= 0) { clearInterval(timerId); timerId = null; }
    paint();
  }, DEMO_TICK_MS);
}

What the numbers say

Item 06 has the highest intensity of the nine at 171.7 across all 23 frames, from a 3.72% area — the pulse scale and the disabled-button/message swap both land on the same frames as the number change.

07Fading attempt history — every new attempt added lowers the opacity value of older entries in the list

Each time one new attempt record is added, every earlier record's opacity value steps down by one level. The step is a flat multiplier from each row's own list index, clamped to a minimum so old rows fade but never fully disappear.

Fading attempt history — every new attempt added lowers the opacity value of older entries in the list
Open in a new tab

07 · Fading attempt history — every new attempt added lowers the opacity value of older entries in the list — Measured area 39.983% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

MAX_ENTRIESOPACITY_STEPMIN_OPACITY
var MIN_OPACITY = 0.16;

var SEQ = ['fail', 'fail', 'success', 'fail', 'fail', 'fail', 'success', 'fail'];
var seqIdx = 0;
var entries = []; // newest at index 0

function fmtTime(n) {
  var m = String(10 + n % 50).padStart(2, '0');
  return '10:' + m;
}

// Calculation layer: opacity of the i-th (0 = newest) entry = max(minimum, 1 - i x step)
function render() {
  list.innerHTML = '';
  for (var i = 0; i < entries.length; i++) {
    var e = entries[i];
    var op = Math.max(MIN_OPACITY, 1 - i * OPACITY_STEP);
    var li = document.createElement('li');
    li.className = 'ahf__row' + (e.result === 'fail' ? ' ahf__row--fail' : ' ahf__row--ok');
    li.style.opacity = op.toFixed(2);
    li.innerHTML = '<span class="ahf__time">' + e.time + '</span>' +
      '<span class="ahf__result">' + e.result + '</span>';
    list.appendChild(li);
  }
}

What the numbers say

Item 07 has the largest measured area of all nine at 39.98%, because every existing row repaints its opacity on each new entry — one click moves the whole list, not just the newest line.

08Reset on success — a single successful login sends the failure-count value straight back to zero

While failures are accumulating, a single success sends the count value straight back to its starting value. The reset plays the same spring integration as item 05 — its own SPRING_K/SPRING_C pair, not item 05's — so the number overshoots past 1 before settling, instead of snapping.

Reset on success — a single successful login sends the failure-count value straight back to zero
Open in a new tab

08 · Reset on success — a single successful login sends the failure-count value straight back to zero — Measured area 0.79% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

MAX_SHOWNSPRING_KSPRING_C
function scaleTick(ts, last) {
  var dt = Math.min(32, ts - last) / 1000;
  var disp = scalePos - scaleTarget;
  var acc = (-SPRING_K * disp - SPRING_C * scaleVel) / SPRING_M;
  scaleVel += acc * dt;
  scalePos += scaleVel * dt;
  countEl.style.transform = 'scale(' + scalePos.toFixed(3) + ')';
  if (Math.abs(disp) > 0.004 || Math.abs(scaleVel) > 0.01) {
    rafId = requestAnimationFrame(function (t) { scaleTick(t, ts); });
  } else {
    scalePos = scaleTarget;
    countEl.style.transform = 'scale(1)';
  }
}

function bounce() {
  if (rafId) cancelAnimationFrame(rafId);
  scalePos = 1.5;
  scaleVel = 0;
  scaleTarget = 1;
  rafId = requestAnimationFrame(function (ts) { scaleTick(ts, ts - 16); });
}

What the numbers say

Item 08 measures one of the smallest areas here at 0.79% and a 133.4 intensity over 10 frames — as a component-scale pattern, its case rests on the spring overshoot itself rather than on covering much of the frame.

09Reduced-motion instant update — under a reduced-motion setting, the value jumps straight to its next state with no movement

When the reduced-motion setting is on, the value skips its gradual change and switches straight to the next state. Both panels decrement the exact same counter on the same click; only the left one tweens through shakeA() while the right one writes the new text with no transition at all.

Reduced-motion instant update — under a reduced-motion setting, the value jumps straight to its next state with no movement
Open in a new tab

09 · Reduced-motion instant update — under a reduced-motion setting, the value jumps straight to its next state with no movement — Measured area 0.868% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

MAX_TRIESAMPSHAKE_MS
function step() {
  remaining = remaining > 0 ? remaining - 1 : MAX_TRIES;
  a.textContent = String(remaining);
  shakeA();
  // Reduced-motion panel: the same value swaps in place with no tween
  b.textContent = String(remaining);
}

root.addEventListener('click', function () {
  root.classList.remove('is-demo');
  stopAutoplay();
  step();
});

var autoTimer = null;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) { autoTimer = setInterval(step, 420); }
else { autoTimer = setInterval(function () { remaining = remaining > 0 ? remaining - 1 : MAX_TRIES; a.textContent = b.textContent = String(remaining); }, 420); }

What the numbers say

Item 09 measures 0.87% area at a 154.0 intensity over 20 frames — comparable to item 08's small footprint, since only a short number swap moves on each panel.

Where values reshape a pattern — threshold count, backoff ratio, wait-time criteria

Backoff first wait. Item 02's BACKOFF_BASE_SEC moves from 1 to 4: since the wait is BACKOFF_BASE_SEC x 2^n, raising the base doesn't change how fast the wait doubles, only how long the very first wait already is before any doubling happens.

Captcha escalation point. Item 04's STAGE_THRESHOLD moves from 2 to 5: this changes how many failures it takes to promote to the next captcha stage, not the fixed cell counts (CELL_STEPS) those stages step through — a higher threshold means more failures tolerated per stage, with the same four possible grid sizes.

Reset spring rebound. Item 08's SPRING_K moves from 220 to 90: a lower stiffness makes the same spring integration settle slower and swing further past 1 before it stops, which is a distinct value from item 05's own SPRING_K of 180 — the two banners are not sharing one constant.

None of these nine patterns decide layout on their own. 9 Login Form Design Patterns You Can Copy-Paste (206) works the calculation axis of where the card and its fields sit, not what a failed try does to a value. 9 Login UI Design Patterns (Copy-Paste) (341) works the axis of screen-to-screen motion around a login flow, one step up from any single card. Password Strength Meter UI: 9 Calculation Patterns (356) works the axis of a typed value scoring itself as it's entered — worth a look if the login attempts limit reached here is only one piece of the screen you're building. The zip below carries all nine sources, unlocked with nmr9ytx5.

Where it breaks — refresh abuse, concurrent multi-tab attempts, reduced motion

Refresh abuse

None of the nine items write to localStorage, sessionStorage, or anything else that survives a reload — every counter, wait value, and lock state lives in a plain JS variable, so reloading the tab resets all nine straight back to their starting constants. A pattern that only enforces a login attempts limit in page memory like this is exactly the kind of demo where "just refresh" defeats the lock; that gap is the point being shown, not a bug to fix here.

Concurrent multi-tab attempts

Opening the same item in a second tab starts an entirely independent copy — there's no BroadcastChannel, shared worker, or storage event tying the two together, so two tabs can run through the same remaining-tries counter or backoff wait at the same time without either one knowing about the other.

Reduced motion

Across items 01–08, prefers-reduced-motion: reduce gates one thing: the autoplay timer (autoTimer) that drives the looping demo simply never starts. Item 09 breaks that pattern — its autoplay timer starts either way, only swapping which function it calls: the tweened step() under full motion, or an inline function that decrements the same counter and writes both panels' text with no tween at all under reduced motion. A real click always runs fail(), render(), tick(), scaleTick(), or step() in full regardless of that OS setting, which is why item 09 can hold a reduced-motion panel and a full-motion panel side by side on every single click rather than only during autoplay. Each item's SCSS separately drops a transition under the same media query — the retry button's own hover transition for every item except 03 and 09: 03 has no separate button transition here and instead zeroes out the progress ring's own stroke transition, and 09 has no <button> element at all and instead zeroes out its count text's transition. None of this is related to the JS-driven counter, ring, or spring animation above it.

Three variants and accessibility

Variant What changes What it doesn't touch
Backoff first wait BACKOFF_BASE_SEC 1 → 4 The doubling ratio itself
Captcha escalation point STAGE_THRESHOLD 2 → 5 The fixed CELL_STEPS sizes
Reset spring rebound SPRING_K 220 → 90 Item 05's own spring constants

None of the nine markup files add an aria-* attribute, a role, or tabindex anywhere — there's no live region announcing the remaining-tries drop or the unlock countdown to a screen reader, so anything beyond what a native element already provides has to be added by whoever adapts this code. What is native: items 01–08 use a real <button type="button"> (08 has two, one for fail and one for success), item 09's trigger is a click listener on its whole .arm panel rather than a button element, and items 02 and 06 set the native disabled attribute on their button while a wait or lockout is running rather than only styling it as disabled. That native disabled state is also the most direct way this demo hints at an account temporarily unable to retry, right up to the point item 06's own message swaps back once the seconds-left-until-unlock value reaches zero.

FAQ

Does the wait value in item 02 keep doubling forever?

No — waitSecondsOf() caps it at CAP_SEC through Math.min, so past a certain failure count the wait stops growing and holds at that ceiling instead of doubling without limit.

Why does item 06 use setInterval while items 05 and 08 use requestAnimationFrame?

Item 06's remaining-seconds value only needs to change once per tick, so a plain setInterval is enough for the countdown itself (its short pulse animation still runs its own frame loop); items 05 and 08 integrate spring position and velocity, which has to be recomputed every rendered frame to look continuous, so they stay on requestAnimationFrame for their whole motion.

Is this how a real "too many failed login attempts. try again later" lockout gets built?

Not on its own — every value here is recalculated in the browser with no server, account database, or real brute-force countermeasure behind it, so treat these nine as the client-side arithmetic and motion layer to adapt, not a finished security system.

Sources: MDN — setInterval · MDN — requestAnimationFrame · MDN — prefers-reduced-motion

Enter the archive password

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