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

9 account security settings UI patterns

Account security settings cover everything after the login screen: devices still signed in, two-factor, the password.

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

The order is the order a person opening a security page gets curious in. First, who is signed in right now (01). Then turning on two-factor, one step at a time (02), saving the backup codes (03), and registering this device so no password is needed (04). Next come the changes: a new password (05), picking where new-device alerts arrive (06), and issuing an app password for a mail client (07). The last two are the watching side. A login history to scan for a stranger (08), and one final typed phrase standing in front of a deletion that cannot be undone (09). Multi factor authentication settings differ from product to product, but the nine parts below are the ones every version of that page ends up needing. The screen before the door belongs to ID Verification Screen UI Patterns, and showing the face after login is inherited by Avatar UI Design.

01Devices signed in right now

What you see

Each row carries a device icon, the city it signed in from, and when it was last used, and the device you are reading on wears its own badge.

Devices signed in right now
Open in a new tab

01 · Devices signed in right now — Measured area 11.214% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateXmax-heightaria-live

Where to look in the code

Press the button on a row and only that row slides out while its height folds away, so the rows below never jump.

.sd.is-demo .sd__row[data-row="3"] {
  animation-name: sd-out;
  animation-duration: $dur-loop;
  animation-timing-function: $easing;
  animation-iteration-count: infinite;
}
@keyframes sd-out {
  0%   { transform: translateX(0); max-height: var(--row-h); background-color: transparent; }
  28%  { transform: translateX(0); max-height: var(--row-h); background-color: transparent; }
  31%  { transform: translateX(0); max-height: var(--row-h); background-color: $error-soft; }
  48%  { transform: translateX(112%); max-height: var(--row-h); background-color: $error-soft; }
  58%  { transform: translateX(112%); max-height: 0; background-color: $error-soft; }
  68%  { transform: translateX(112%); max-height: var(--row-h); background-color: $error-soft; }
  84%  { transform: translateX(0); max-height: var(--row-h); background-color: $error-soft; }
  87%  { transform: translateX(0); max-height: var(--row-h); background-color: transparent; }
  100% { transform: translateX(0); max-height: var(--row-h); background-color: transparent; }
}

02Three steps to turn on two-factor

How two factor authentication settings advance on screen

Scanning the code, typing the six digits back, and saving the backup codes each open in turn inside one card.

Three steps to turn on two-factor
Open in a new tab

02 · Three steps to turn on two-factor — Measured area 18.638% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

aria-currentsteps(1, end)scaleX

How to read the code

Every step fills one more notch on the bar above, and a step you have passed does not reopen.

.tf.is-demo .tf__panel {
  animation-duration: $dur-loop, $dur-loop;
  animation-timing-function: steps(1, end), $easing;
  animation-iteration-count: infinite, infinite;
}
.tf.is-demo .tf__panel[data-panel="1"] { animation-name: tf-cut1, tf-rise1; }
.tf.is-demo .tf__panel[data-panel="2"] { animation-name: tf-cut2, tf-rise2; }
.tf.is-demo .tf__panel[data-panel="3"] { animation-name: tf-cut3, tf-rise3; }

@keyframes tf-cut1 { 0% { opacity: 1; } 30% { opacity: 0; } 86% { opacity: 1; } 100% { opacity: 1; } }
@keyframes tf-cut2 { 0% { opacity: 0; } 30% { opacity: 1; } 60% { opacity: 0; } 100% { opacity: 0; } }
@keyframes tf-cut3 { 0% { opacity: 0; } 60% { opacity: 1; } 86% { opacity: 0; } 100% { opacity: 0; } }

03Backup codes struck through once they are used

What moves

Six nine-character codes sit in a two-column grid inside the card, and a code already spent keeps a line through its middle and fades.

Backup codes struck through once they are used
Open in a new tab

03 · Backup codes struck through once they are used — Measured area 8.319% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password

line-throughnavigator.clipboardaria-live

What to change

The button below hands the whole list to the clipboard and announces that moment in a live region as well.

btn.addEventListener('click', function () {
  root.classList.remove('is-demo');        // a human touched it, so the auto loop goes off
  clearTimeout(t);
  root.setAttribute('data-copied', '1');
  var txt = fullCode();
  if (navigator.clipboard && navigator.clipboard.writeText) {
    navigator.clipboard.writeText(txt);
  }
  live.textContent = 'Copied 6 backup codes to the clipboard';
  t = setTimeout(function () { root.setAttribute('data-copied', '0'); }, 1600);
});

04Passkey enrollment turning from finger to shield

What you see

A fingerprint in the middle swells and settles over and over to show the device is waiting for a finger, and when enrollment lands it cuts to a shield in a single frame.

Passkey enrollment turning from finger to shield
Open in a new tab

04 · Passkey enrollment turning from finger to shield — Measured area 10.955% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

steps(1, end)aria-busyscale

Where to look in the code

While it waits, the button stays locked and that fact is written into an attribute too.

// the arc is visible only during the waiting stretch — spin and cut are stacked on one element
.pk.is-demo .pk__arc {
  animation-name: pk-spin, pk-busy;
  animation-duration: 900ms, $dur-loop;
  animation-timing-function: linear, steps(1, end);
  animation-iteration-count: infinite, infinite;
}

// the fingerprint keeps swelling and shrinking while it waits
.pk.is-demo .pk__ic[data-ic="print"] {
  animation-name: pk-print, pk-pulse;
  animation-duration: $dur-loop, $dur-loop;
  animation-timing-function: steps(1, end), $ease-spring;
  animation-iteration-count: infinite, infinite;
}

05Password change that opens when three fields agree

What happens on screen

Saving stays shut until the current password, the new one, and the confirmation all meet their rule.

Password change that opens when three fields agree
Open in a new tab

05 · Password change that opens when three fields agree — Measured area 18.794% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

grid-template-columnsaria-describedbyaria-disabled

How to read the code

The four-notch meter under the new field is laid out as a grid so the notches never drift, and a mismatched confirmation leaves that one row red with its reason attached.

// ── four-segment strength meter — grid divides the segment widths (scaleX only fills inside a segment) ──
.pw__meter {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: $sp-1;
  margin-top: $sp-1;
}
.pw__cell {
  display: block;
  overflow: hidden;
  height: 6px;
  border-radius: $r-pill;
  background-color: rgba($subject-ink, .14);
}

06A row of switches for new-device alerts

What moves

Mail, push, and text each hold one switch and the knob slides left or right.

A row of switches for new-device alerts
Open in a new tab

06 · A row of switches for new-device alerts — Measured area 12.649% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

role="switch"aria-checkedtranslateX

What to change

Flip one and a saved line lights up below and stays until the next change, so the poster frame is never an empty card.

// how it really works: it is a <button> with role="switch", so Space and Enter arrive as click — nothing is intercepted separately
var root = document.querySelector('.al');
var switches = root.querySelectorAll('.al__sw');
Array.prototype.forEach.call(switches, function (sw) {
  sw.addEventListener('click', function () {
    root.classList.remove('is-demo');   // a human touched it, so the auto loop goes off
    var on = sw.getAttribute('aria-checked') !== 'true';
    sw.setAttribute('aria-checked', String(on));
    root.setAttribute('data-saved', 'true');   // the saved line does not vanish; it stays until the next change
  });
});

07App password shown exactly once

What you see

Press issue and the cover peels off the value cell by cell from the left, and once it has been copied the same slot is buried under dots for good.

App password shown exactly once
Open in a new tab

07 · App password shown exactly once — Measured area 8.803% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

navigator.clipboardsteps(16, end)aria-live

Where to look in the code

The warning that the value appears only once sits on the card before anything is revealed.

// ── auto loop: the cover peels away in 16 stepped notches, and what it uncovers stays uncovered (no exit is replayed) ──
.tk.is-demo .tk__cover {
  animation-name: tk-strip;
  animation-duration: $dur-loop;
  animation-timing-function: steps(16, end);
  animation-iteration-count: infinite;
}
@keyframes tk-strip {
  0%   { width: 100%; }
  10%  { width: 100%; }
  56%  { width: 0%; }
  100% { width: 0%; }
}

08Login history holding successes and failures together

What happens on screen

Times and places stack along a vertical rail and the dot color splits success from failure.

Login history holding successes and failures together
Open in a new tab

08 · Login history holding successes and failures together — Measured area 36.643% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

::beforebox-shadowtranslateY

How to read the code

A row that arrived from an unusual country gains an extra outline and breathes slowly on its own, so it catches the eye first.

// ── auto loop: rows rise one by one on staggered translateY. no shorthand · delays require backwards ──
// rise (transform) and switch-on (opacity) are bound separately — interpolating opacity along with it leaves half-transparent ghost frames
.lh.is-demo .lh__item {
  animation-name: lh-rise, lh-show;
  animation-duration: $dur-loop, $dur-loop;
  animation-timing-function: $easing, steps(1, end);
  animation-iteration-count: infinite, infinite;
  animation-fill-mode: backwards, backwards;
}
.lh.is-demo .lh__item[data-i="1"] { animation-delay: 40ms, 40ms; }
.lh.is-demo .lh__item[data-i="2"] { animation-delay: 200ms, 200ms; }
.lh.is-demo .lh__item[data-i="4"] { animation-delay: 360ms, 360ms; }
.lh.is-demo .lh__item[data-i="3"] { animation-delay: 520ms, 520ms; }

// once a row is up it stays up (no exit is replayed). 22% × 2s = it settles in 440ms
@keyframes lh-rise {
  0%   { transform: translateY(16px); }
  22%  { transform: translateY(0); }
  100% { transform: translateY(0); }
}

09Account delete that opens only when you type it out

What moves

Delete stays locked until the exact word is typed into the field inside the red zone, and only a matching string opens the button.

Account delete that opens only when you type it out
Open in a new tab

09 · Account delete that opens only when you type it out — Measured area 38.426% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

role="alertdialog"aria-disabledoutline

What to change

The dialog it raises opens with alertdialog semantics, so focus lands inside it first and the warning gets read out.

dialog.addEventListener('keydown', function (e) {
  if (e.key === 'Escape') { openDialog(false); return; }
  if (e.key !== 'Tab') return;
  e.preventDefault();                            // focus cannot leave the confirm dialog
  (document.activeElement === noBtn ? yesBtn : noBtn).focus({ preventScroll: true });
});

Where it breaks — the trap

Blend one state into the next with opacity and the 24-frame preview catches the two of them in a single half-transparent ghost. The three step panels of 02 were first written as a fade, until the frame that changed the most turned out to be the QR code, the six-digit field, and the finished shield stacked three deep in one image. The swap is now a single-frame cut with steps(1, end), with a translateY pop and a scaleX notch fill running beside it to keep the moving-frame count up. That version of 02 registers movement in 22 of 24 frames. The row rise in 08 splits the same way: the travel rides a spring easing while the appearance cuts in one frame.

Narrow screens got a rule. At 320px wide the stage is 200px tall, and the nine parts measure between 116px (07) and 172px (05) in height. 05, which holds three fields plus a meter and a hint line, is the tallest, leaving 28px of room. So on narrow screens values go down, never up. In 06 the switch track shrinks from 40×22px to 32×18px, but the 18px knob travel inside the keyframes lives outside the media query and would overhang by 2px. The narrow block therefore redefines all three keyframes — al-knob-on, al-knob-push, al-knob-sms — wholesale at a 14px travel.

Measured, the nine split by character. 09 moves the most area and the hardest: 38.4% coverage at an intensity of 160.7, because the dialog plus scrim cutting open in one frame repaints that much screen at once. 03 covers only 8.3% yet hits the second-highest intensity at 152.6 — a strike line is a thin bar, small in area, but solid red on a light card is a big difference. 08 logs 36.6% at the instant its three ordinary rows dim together at the 50% mark, the same rule as ever: three rows changing at once beats one row arriving. The cover in 07 peels in 16 steps across the 10%-to-56% span, one cell every 57.5ms, with the issue button's press recoil running alongside to hold the frame count at 17.

Building 08 settled a rule about delays. Four rows rise staggered at 40ms, 200ms, 360ms, and 520ms, each delay paired with animation-fill-mode: backwards. Without the fill mode each row waits out its delay in its authored position, so frame 0 shows the finished list for one blink before it disappears. The order also ignores DOM order on purpose: the unusual-country row sits third in the DOM but rises last at 520ms, because the row that arrives last is the row the eye stops on. And the four notches in 05 avoid animation-delay entirely — each cell carries its own keyframes with its own timetable, since one animation: shorthand in a shared rule resets every delay to zero and makes all cells move at once.

04 bought its measurement with one spinning arc. A state swap made purely of steps(1, end) cuts leaves two moving frames and fails the render gate. So a 900ms arc, visible only while the device waits, rides the same element as the cut — two animation names on one comma list, the spin timed linear and the visibility cut steps(1, end). The step panels of 02 share that wiring, with all three notches resetting together from 84% to 86%, and the sign-out notice in 01 cutting on between 46% and 48% and holding until 84%. The six-digit field in 02 carries inputmode="numeric" and maxlength="6" so the mobile keyboard opens as a number pad first, and every focus move goes through focus({ preventScroll: true }) — a demo sits in an iframe on the home grid, and a parent page that scrolls itself whenever focus moves inside one is a bug you can watch from outside. When rows need to be swiped away, Swipe to Delete List UI Patterns reuses that same hand. The archive holding all nine, vanilla and React with the same values, is locked with the password eeu22zhr and nothing else.

Accessibility

With prefers-reduced-motion: reduce on, all nine loops stop and only the result states remain. Stopping is only half of it: each part pins its arrival state. 05 freezes with all four notches filled and the save button active; 08 keeps all four rows crisp with only the risky row wearing a red outline; 09 rests on the open dialog. Every declaration in the reduced-motion block carries !important — without it, animation: none loses to a specificity of (0,3,0) and the loop keeps running — a trap measured in episode 260.

The switches are real <button> elements with role="switch", so Space and Enter arrive as ordinary clicks with no key handling at all. Locks use aria-disabled instead of the native disabled (05 and 09, and the copy button in 07 — its issue button uses the native kind). A button that vanishes teaches a keyboard user nothing, except that Tab suddenly skips it. Focus stays, and pressing a locked button surfaces the reason: the hint line aria-describedby points at in 05, and the live region plus the warning already on the card in 07. The delete dialog is an alertdialog whose name and description point at text already on the screen, and once it opens, focus moves inside and Tab cycles between its two buttons only.

Part To the eye To a screen reader Operated by
01 session list row slides out and folds role="status" reads the sign-out button click
02 three steps notches fill one by one aria-current="step" moves per step Next · Enter
03 backup codes spent codes struck through role="status" reads the copy moment Copy all
04 passkey finger swells, cuts to shield aria-busy announces the wait enroll click
05 password change four notches · red mismatch row aria-describedby line answers why every keystroke
06 alert switches knob slides role="switch" + aria-checked click · Space
07 app password cover peels in 16 cells role="status" reads issue and copy issue → copy
08 login history risky row outlined, breathing rows carry aria-pressed for review row click
09 danger zone opens only when DELETE is typed alertdialog with focus trapped typing · Escape

How to mark up a switch is documented in the MDN switch role page, and opening a dialog as a warning in the alertdialog role page. The login cards themselves are collected in 9 login screen designs and the identity checks in 9 identity verification screens.

FAQ

Can the two-factor steps advance on CSS alone?

The notches, yes. Filling them one by one with scaleX is pure CSS. Judging whether a step was passed correctly is not. Comparing the six digits against the real code and letting that result open the next step is JavaScript, which is why 02 moves aria-current="step" from script. The notches are the picture; the step is the state. For a panel of ever-changing values, Stock Ticker UI shows the same slot-filling.

Are there browsers where the backup-code copy fails?

Yes. navigator.clipboard does not exist outside a secure context. So 03 checks navigator.clipboard && navigator.clipboard.writeText before calling it, and when the API is missing the clipboard is left alone while the label and the announced line still switch. Throwing an error at the user was traded for a screen that reports its own state. In a real product you would add one line inviting a manual select-and-copy here.

Why does a session management ui row fold its height when it signs out?

Slide the row out and leave the space, and the list keeps a hole. Animating max-height down to 0 instead of height lifts the rows below smoothly. 01 keeps the slide and the fold side by side inside one keyframe block, and the way back in uses the same easing so the row never teleports. The list height itself is fixed, so nothing else in the card moves while a row folds.

Enter the archive password

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