계정 보안 설정 UI 9종 — 로그인한 뒤의 화면
계정 보안 설정은 로그인을 통과한 사람이 마주하는, 접속 중인 기기와 2단계 인증, 비밀번호를 직접 만지는 화면입니다. 아홉 개는 전부 실제로 돌아갑니다. 기기 줄은 정말로 접히고, 백업 코드는 정말로 클립보드에 들어가고, 삭제 버튼은 DELETE 를 직접 쳐야 열립니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 지금 로그인된 기기 목록
- 02 2단계 인증을 켜는 세 걸음
- 03 한 번 쓰면 줄이 그어지는 백업 코드
- 04 지문에서 방패로 갈리는 패스키 등록
- 05 세 칸이 맞아야 열리는 비밀번호 변경
- 06 새 기기 로그인 알림 스위치 묶음
- 07 한 번만 보여 주는 앱 비밀번호
- 08 성공과 실패가 함께 놓인 로그인 기록
- 09 글자를 따라 쳐야 열리는 계정 삭제
순서는 보안 화면을 처음 여는 사람이 궁금해지는 순서입니다. 먼저 누가 들어와 있는지 봅니다(01). 2단계 인증을 켜려고 걸음을 밟고(02), 백업 코드를 받아 두고(03), 비밀번호 없이 들어올 수 있게 이 기기를 등록합니다(04). 다음은 비밀번호를 바꾸고(05), 낯선 기기가 들어오면 알림을 받도록 고르고(06), 메일 앱에 물릴 앱 비밀번호를 발급합니다(07). 마지막 둘은 지켜보는 쪽입니다. 지나간 로그인을 훑어 낯선 접속을 찾아내고(08), 되돌릴 수 없는 삭제 앞에 마지막 문구를 하나 둡니다(09).
01지금 로그인된 기기 목록
기기 아이콘과 접속한 도시, 마지막으로 쓴 시각이 한 줄에 놓이고 지금 보고 있는 기기에는 따로 배지가 붙습니다. 줄 오른쪽 버튼을 누르면 그 줄만 옆으로 밀려 나가면서 높이까지 같이 접혀 아래 줄이 튀지 않습니다.
.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; }
}
022단계 인증을 켜는 세 걸음
앱으로 코드를 읽는 자리, 여섯 자리를 되돌려 넣는 자리, 백업 코드를 받아 두는 자리가 한 카드 안에서 순서대로 열립니다. 걸음마다 위쪽 눈금이 한 칸씩 차고 지나간 걸음은 다시 열리지 않습니다.
.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; } }
03한 번 쓰면 줄이 그어지는 백업 코드
아홉 글자 코드 여섯 개가 카드 안에 두 칸씩 세 줄로 놓이고 이미 쓴 코드에는 가운데 줄이 그어져 흐리게 남습니다. 아래 버튼은 목록 전체를 클립보드로 넘기고 넘어간 순간을 읽히는 영역으로도 한 번 알립니다.
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);
});
04지문에서 방패로 갈리는 패스키 등록
가운데 지문 그림이 부풀었다 빠지기를 반복하며 기기가 손가락을 기다린다는 것을 보여 주고, 등록이 끝나면 방패 표시로 한 프레임에 끊겨 바뀝니다. 기다리는 동안 버튼은 잠기고 그 사실이 속성으로도 적힙니다.
// 호는 기다리는 구간에만 보인다 — 회전과 컷을 한 요소에 겹쳐 건다
.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;
}
05세 칸이 맞아야 열리는 비밀번호 변경
지금 쓰는 비밀번호와 새 비밀번호, 확인 칸이 모두 조건을 채워야 저장이 열립니다. 새 비밀번호 아래 네 칸 눈금은 격자로 잘라 두어 칸 폭이 어긋나지 않고, 확인 칸이 다르면 그 줄만 붉게 남아 이유를 답니다.
// ── 네 칸 강도 눈금 — 칸 폭은 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);
}
06새 기기 로그인 알림 스위치 묶음
메일과 푸시, 문자 세 줄이 스위치를 하나씩 쥐고 손잡이가 좌우로 미끄러집니다. 하나를 바꾸면 아래에 저장됐다는 줄이 켜지는데, 그 줄은 사라지지 않고 다음 변경까지 그대로 남아 대표 이미지가 빈 화면이 되지 않습니다.
// 실제 동작: 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'); // 저장 줄은 사라지지 않고 다음 변경까지 남는다
});
});
07한 번만 보여 주는 앱 비밀번호
발급을 누르면 가려져 있던 자리가 왼쪽부터 칸 단위로 걷히며 글자가 드러나고, 복사를 마치면 같은 자리가 점으로 덮여 다시 열리지 않습니다. 값이 한 번만 보인다는 경고는 드러나기 전부터 카드에 적혀 있습니다.
// ── 자동 루프: 덮개가 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%; }
}
08성공과 실패가 함께 놓인 로그인 기록
세로선을 따라 시각과 장소가 쌓이고 점 색으로 성공과 실패가 갈립니다. 평소와 다른 나라에서 들어온 줄에는 테두리가 한 겹 더 붙고 그 줄만 천천히 숨을 쉬어 먼저 눈에 걸립니다.
// ── 자동 루프: 줄들이 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); }
}
09글자를 따라 쳐야 열리는 계정 삭제
붉은 구역 안 입력칸에 정해진 낱말을 그대로 쳐 넣기 전에는 삭제가 잠겨 있고, 글자가 맞는 순간에만 버튼이 열립니다. 눌러서 뜨는 확인창은 경고를 읽어 주는 역할로 열려 초점이 먼저 그 안으로 들어갑니다.
창.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { 열기(false); return; }
if (e.key !== 'Tab') return;
e.preventDefault(); // 초점이 확인창 밖으로 못 나간다
(document.activeElement === 아니오 ? 예 : 아니오).focus({ preventScroll: true });
});
어디서 깨지는가 — 함정 하나
상태가 바뀌는 장면을 opacity 로 부드럽게 넘기면 24프레임 미리보기에서 두 장면이 반투명하게 겹친 유령이 찍힙니다. 02 의 세 걸음 패널을 처음에는 페이드로 갈았다가, QR 과 여섯 자리 칸, 완료 방패가 한 화면에 세 겹으로 겹쳐 보이는 장면이 대표 이미지로 뽑히는 것을 보고 컷으로 갈았습니다. 화면 교체는 steps(1, end) 로 한 프레임에 끊고, 그 옆에 translateY 팝과 scaleX 눈금 채우기를 함께 걸어 움직이는 프레임 수를 채웠습니다. 이 결과 02 는 렌더 24장 중 22장이 움직이는 프레임으로 잡혔습니다. 같은 이유로 08 의 줄 떠오름도 이동은 스프링 이징으로, 켜짐은 steps(1, end) 로 갈라 걸었습니다.
좁은 화면은 규칙을 세워 두었습니다. 320px 폭에서 무대는 세로 200px 인데 아홉 부품의 실제 높이를 재니 116px(07)에서 172px(05) 사이였습니다. 세 칸과 눈금·안내 문구가 전부 들어가는 05 가 가장 컸고, 여유는 28px 였습니다. 그래서 좁은 화면에서는 값을 늘리지 않고 줄입니다. 06 은 스위치 트랙이 40×22px 에서 32×18px 로 줄어드는데, 키프레임 안의 손잡이 이동거리 18px 는 미디어쿼리 밖 값이라 그대로면 2px 넘칩니다. 그래서 좁은 폭 블록 안에서 al-knob-on·al-knob-push·al-knob-sms 세 키프레임을 이동거리 14px 로 통째로 다시 정의했습니다.
측정 관점에서도 이 묶음은 갈린 성격이 납니다. 09 는 누적면적 38.4% 에 강도 160.7 로 아홉 중 가장 크고 또렷하게 움직입니다. 확인창이 스크림째 한 프레임에 열리는 장면이 그만큼의 화면을 한꺼번에 바꾸기 때문입니다. 반대로 03 은 누적면적 8.3% 인데 강도는 152.6 으로 둘째로 높습니다. 취소선이 가는 막대라 면적은 작아도 밝은 카드 위 짙은 붉은 선의 대비는 큽니다. 08 은 네 줄이 시차로 떠오르다가 50% 지점에서 세 줄이 동시에 어두워지는 구간이 최대 변화라 36.6% 를 기록했습니다. 줄 하나가 떠오르는 것보다 세 줄이 한꺼번에 바뀌는 쪽이 면적을 크게 만든다는 규칙을 이 편에서도 그대로 썼습니다. 07 의 덮개는 16칸 steps 로 끊어 걷히는데, 폭이 변하는 구간이 10% 에서 56% 사이라 한 칸에 57.5ms 씩 떨어지고 그 사이 발급 버튼이 눌리는 반동을 옆에 걸어 움직이는 프레임 17장을 채웠습니다.
08 을 짜면서 지연을 다루는 규칙 하나를 정리했습니다. 네 줄이 40ms·200ms·360ms·520ms 씩 시차를 두고 떠오르는데, 이 지연은 animation-delay 로 주되 반드시 animation-fill-mode: backwards 와 짝을 이룹니다. fill-mode 가 없으면 지연 구간 동안 요소가 작성 상태 그대로 그려져 0 프레임에 완성된 목록이 한 번 떴다가 사라지는 번쩍임이 찍힙니다. 순서도 DOM 순서와 다르게 짰습니다. 평소와 다른 나라 줄이 DOM 에는 세 번째지만 지연은 520ms 로 가장 늦게 떠오릅니다. 마지막에 도착하는 줄이 곧 눈이 멈추는 줄이기 때문입니다. 또 05 의 네 칸 눈금은 animation-delay 를 쓰지 않고 칸마다 자기 시각표를 가진 키프레임 넷으로 짰습니다. 공통 규칙에 animation: 단축을 쓰면 delay 가 0 으로 리셋되어 240 편에서 다섯 칸이 동시에 움직였던 함정을 다시 밟게 됩니다.
04 를 만들 때는 회전하는 호 하나로 측정을 구했습니다. 상태 교체를 steps(1, end) 컷으로만 짜면 프레임이 두 장 남아 렌더 측정이 통과하지 못합니다. 그래서 기다리는 구간에만 900ms 주기로 도는 호를 같은 요소에 겹쳐 걸고, 컷은 지문에서 방패로 갈리는 순간에만 쓰게 했습니다. 애니메이션 이름을 쉼표로 둘 올리고 타이밍 함수를 회전하는 호에는 linear, steps(1, end) 를, 지문에는 steps(1, end), $ease-spring 을 각각 지정하는 방식입니다. 02 의 세 걸음 패널도 같은 결선으로 짰는데, 세 장의 패널을 겹쳐 두고 눈금 바는 84% 에서 86% 로 세 칸 동시에 리셋합니다. 걸음을 지나간 흔적이 한꺼번에 지워지는 쪽이 잔상이 덜 남습니다. 01 의 되돌아오는 길은 같은 스프링 이징으로 밀어 넣어 텔레포트로 보이지 않게 했고, 셋째 줄이 다시 펴진 뒤 87% 에 배경만 홀로 지웁니다. 로그아웃을 눌렀을 때의 알림 문구도 46% 에서 48% 로 두 퍼센트 사이에 컷으로 켜고 84% 까지 유지합니다. 반투명 중간 프레임을 만들지 않기 위해서입니다. 세 걸음의 여섯 자리 칸은 inputmode="numeric" 과 maxlength="6" 을 함께 두어 모바일 자판이 숫자판으로 먼저 뜨게 했고, 포커스를 옮길 때는 focus({ preventScroll: true }) 로 부모 페이지가 끌려가지 않게 막았습니다. 데모는 홈 목록에 iframe 으로 박혀 있으니 그 안에서 초점이 옮겨질 때마다 위 페이지가 스크롤되면 안 됩니다. 아홉 벌의 규칙을 한 폴더에 모아 둔 압축 파일의 비밀번호는 eeu22zhr 이고 안에는 지금 보고 있는 같은 값으로 들어 있습니다.
접근성
이 모음에서 prefers-reduced-motion: reduce 를 켠 방문자에게는 아홉 벌의 반복 루프가 전부 멈추고 결과 상태만 남습니다. 이때 끄는 데서 끝내지 않고 도착 상태를 고정합니다. 05 는 눈금 네 칸이 전부 차고 저장 버튼이 활성 색인 끝난 상태로, 08 은 네 줄이 다 또렷한 채 위험 줄에만 붉은 테두리가 남고, 09 는 확인창이 열린 화면으로 멈춥니다. reduced-motion 블록의 선언에는 모두 !important 를 붙였습니다 — .al.is-demo .al__saved(0,3,0) 같은 높은 특이도에 animation: none 이 지면 260 편에서 실측했듯 애니메이션이 계속 돕니다.
스위치는 꾸며낸 <div> 가 아니라 role="switch" 를 가진 진짜 <button> 이라 Space 와 Enter 가 그대로 click 으로 옵니다. 잠금은 진짜 disabled 대신 aria-disabled 를 썼습니다(05·09 — 07 의 복사 버튼도). 버튼이 사라지면 키보드 사용자는 왜 갑자기 Tab 이 건너뛰는지 모릅니다. 포커스는 남겨 두고 눌렀을 때 이유 문구가 aria-describedby 가 가리키는 줄에 뜨게 했습니다. 삭제 확인창은 role="alertdialog" 에 aria-labelledby·aria-describedby 로 화면에 이미 있는 글을 가리키고, 열리면 초점이 그 안으로 들어가 Tab 은 두 버튼 사이만 돕니다.
| 항목 | 눈으로 | 낭독기로 | 조작 |
|---|---|---|---|
| 01 접속 기기 목록 | 줄이 밀려 나가며 접힘 | role="status" 가 로그아웃 문구를 읽음 |
버튼 클릭 |
| 02 인증 세 걸음 | 눈금이 한 칸씩 참 | aria-current="step" 이 걸음마다 이동 |
다음 · Enter |
| 03 백업 코드 | 쓴 코드에 줄 | role="status" 가 복사 순간을 읽음 |
전체 복사 |
| 04 패스키 등록 | 지문이 부풀고 방패로 컷 | aria-busy 가 기다림을 알림 |
등록 클릭 |
| 05 비밀번호 변경 | 눈금 4칸 · 어긋남 붉은 줄 | aria-describedby 줄이 이유를 답함 |
한 글자마다 |
| 06 알림 스위치 | 손잡이 미끄러짐 | role="switch" + aria-checked |
클릭 · Space |
| 07 앱 비밀번호 | 덮개가 16칸으로 걷힘 | role="status" 가 발급·복사를 읽음 |
발급 → 복사 |
| 08 로그인 기록 | 위험 줄 테두리·숨쉼 | 줄이 aria-pressed 로 확인 표시 |
줄 클릭 |
| 09 위험 구역 | DELETE 를 쳐야 열림 | role="alertdialog" + 초점 가두기 |
입력 · Escape |
스위치 역할을 어떻게 지정하는지는 MDN 의 switch 역할 문서에, 확인창을 경고로 여는 법은 alertdialog 역할 문서에 정리되어 있습니다. 로그인 화면 카드는 로그인 화면 디자인 9가지에, 본인인증 카드는 본인인증 화면 UI 9가지에 모여 있습니다.
FAQ
2단계 인증 걸음을 CSS 만으로 넘길 수는 없나요?
눈금 채우기까지는 됩니다. scaleX 로 한 칸씩 차게 하는 것은 순수 CSS 입니다. 다만 걸음이 올바르게 지나갔는지 판단하는 것은 CSS 가 못 합니다. 여섯 자리 코드가 진짜 맞는지 비교하고 그 결과를 다음 걸음 열림에 반영하는 일은 자바스크립트가 합니다. 02 가 aria-current="step" 을 JS 로 옮기는 이유입니다. 눈금은 화면이고, 걸음은 상태입니다.
백업 코드 복사가 안 되는 브라우저가 있나요?
있습니다. navigator.clipboard 는 보안 컨텍스트가 아니면 없습니다. 그래서 03 은 복사를 부르기 전에 navigator.clipboard && navigator.clipboard.writeText 를 먼저 확인하고, 없으면 클립보드는 건드리지 않으면서 라벨과 읽히는 문구는 여전히 갈립니다. 사용자에게 에러를 던지는 대신 화면이 상태를 알리는 쪽을 택한 것입니다. 실제 서비스라면 여기에 임시 선택 복사 안내를 한 줄 더 두는 편이 안전합니다.
기기 목록 줄을 지울 때 왜 높이까지 접나요?
줄만 옆으로 밀고 자리를 비워 두면 목록 사이에 빈 공간이 남습니다. 줄의 height 는 내용에 따라 저절로 정해지는 값이라 목표 없이는 움직이지 못하니, max-height 를 줄 높이에서 0 으로 줄여 아래 줄이 부드럽게 올라오게 합니다. 01 은 transform 로 밀어내는 구간과 max-height 로 접는 구간을 한 키프레임 안에 나란히 두었고, 되돌아오는 길도 같은 이징으로 밀어 넣어 텔레포트로 보이지 않게 했습니다. 목록 전체 높이를 고정해 두었으므로 접혀도 카드의 다른 부분은 움직이지 않습니다.