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
- 01 Devices signed in right now
- 02 Three steps to turn on two-factor
- 03 Backup codes struck through once they are used
- 04 Passkey enrollment turning from finger to shield
- 05 Password change that opens when three fields agree
- 06 A row of switches for new-device alerts
- 07 App password shown exactly once
- 08 Login history holding successes and failures together
- 09 Account delete that opens only when you type it out
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).
01Devices signed in right now
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. 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
Scanning the code, typing the six digits back, and saving the backup codes each open in turn inside one card. 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
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. 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'); // 사람이 만졌으니 자동 루프를 끈다
clearTimeout(t);
root.setAttribute('data-copied', '1');
var txt = 코드전체();
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(txt);
}
live.textContent = '백업 코드 6개를 클립보드에 복사했습니다';
t = setTimeout(function () { root.setAttribute('data-copied', '0'); }, 1600);
});
04Passkey enrollment turning from finger to shield
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. While it waits, the button stays locked and that fact is written into an attribute too.
// 호는 기다리는 구간에만 보인다 — 회전과 컷을 한 요소에 겹쳐 건다
.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;
}
// 지문은 기다리는 동안 부풀었다 줄기를 반복한다
.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
Saving stays shut until the current password, the new one, and the confirmation all meet their rule. 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.
// ── 네 칸 강도 눈금 — 칸 폭은 grid 가 나눈다 (scaleX 는 칸 안쪽 채우기에만) ──
.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
Mail, push, and text each hold one switch and the knob slides left or right. Flip one and a saved line lights up below and stays until the next change, so the poster frame is never an empty card.
// 실제 동작: role="switch" 인 <button> 이라 Space·Enter 가 그대로 click 으로 온다 — 따로 가로채지 않는다
var root = document.querySelector('.al');
var 스위치 = root.querySelectorAll('.al__sw');
Array.prototype.forEach.call(스위치, function (sw) {
sw.addEventListener('click', function () {
root.classList.remove('is-demo'); // 사람이 만졌으니 자동 루프를 끈다
var on = sw.getAttribute('aria-checked') !== 'true';
sw.setAttribute('aria-checked', String(on));
root.setAttribute('data-saved', 'true'); // 저장 줄은 사라지지 않고 다음 변경까지 남는다
});
});
07App password shown exactly once
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. The warning that the value appears only once sits on the card before anything is revealed.
// ── 자동 루프: 덮개가 16칸으로 끊어지며 걷히고, 드러난 자리는 그대로 둔다 (퇴장을 재현하지 않는다) ──
.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
Times and places stack along a vertical rail and the dot color splits success from failure. A row that arrived from an unusual country gains an extra outline and breathes slowly on its own, so it catches the eye first.
// ── 자동 루프: 줄들이 translateY 시차로 하나씩 떠오른다. 단축 금지 · 지연에는 backwards 필수 ──
// 떠오름(transform)과 켜짐(opacity)을 갈라 건다 — opacity 를 같이 보간하면 반투명 유령 프레임이 생긴다
.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; }
// 한 번 올라온 줄은 그대로 머문다 (퇴장을 재현하지 않는다). 22% × 2s = 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
Delete stays locked until the exact word is typed into the field inside the red zone, and only a matching string opens the button. The dialog it raises opens with alertdialog semantics, so focus lands inside it first and the warning gets read out.
창.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { 열기(false); return; }
if (e.key !== 'Tab') return;
e.preventDefault(); // 초점이 확인창 밖으로 못 나간다
(document.activeElement === 아니오 ? 예 : 아니오).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. 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.
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 the row fold its height too 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.