08성공과 실패가 함께 놓인 로그인 기록
세로선을 따라 시각과 장소가 쌓이고 점 색으로 성공과 실패가 갈립니다. 평소와 다른 나라에서 들어온 줄에는 테두리가 한 겹 더 붙고 그 줄만 천천히 숨을 쉬어 먼저 눈에 걸립니다.
쓰임최근 접속 기록을 훑어 낯선 접속을 찾아내는 보안 화면
::beforebox-shadowtranslateY

<main class="stage">
<section class="lh is-demo">
<ul class="lh__list">
<li class="lh__item" data-ok="yes" data-i="1">
<span class="lh__dot" aria-hidden="true"></span>
<button class="lh__row" type="button" aria-pressed="false">
<span class="lh__ic" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 256 256"><g fill="currentColor"><path d="M192 56v144H64V56Z" opacity=".2"/><path d="M176 16H80a24 24 0 0 0-24 24v176a24 24 0 0 0 24 24h96a24 24 0 0 0 24-24V40a24 24 0 0 0-24-24M72 64h112v128H72Zm8-32h96a8 8 0 0 1 8 8v8H72v-8a8 8 0 0 1 8-8m96 192H80a8 8 0 0 1-8-8v-8h112v8a8 8 0 0 1-8 8"/></g></svg></span>
<span class="lh__body">
<span class="lh__when">오늘 09:41</span>
<span class="lh__where">서울 · 크롬 · 안드로이드</span>
</span>
<span class="lh__tag">성공</span>
</button>
</li>
<li class="lh__item" data-ok="yes" data-i="2">
<span class="lh__dot" aria-hidden="true"></span>
<button class="lh__row" type="button" aria-pressed="false">
<span class="lh__ic" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 72v104H40V72a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M232 168h-8V72a24 24 0 0 0-24-24H56a24 24 0 0 0-24 24v96h-8a8 8 0 0 0-8 8v16a24 24 0 0 0 24 24h176a24 24 0 0 0 24-24v-16a8 8 0 0 0-8-8M48 72a8 8 0 0 1 8-8h144a8 8 0 0 1 8 8v96H48Zm176 120a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-8h192ZM152 88a8 8 0 0 1-8 8h-32a8 8 0 0 1 0-16h32a8 8 0 0 1 8 8"/></g></svg></span>
<span class="lh__body">
<span class="lh__when">오늘 08:02</span>
<span class="lh__where">대전 · 사파리 · 맥</span>
</span>
<span class="lh__tag">성공</span>
</button>
</li>
<li class="lh__item" data-ok="yes" data-flag="yes" data-i="3">
<span class="lh__dot" aria-hidden="true"></span>
<button class="lh__row" type="button" aria-pressed="false">
<span class="lh__ic" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 256 256"><g fill="currentColor"><path d="M213.09 172.48a96 96 0 0 1-80.41 51.41l3.17-16.44a8 8 0 0 0-2-6.95l-19.74-20.33a8 8 0 0 1-1.44-8.69l13.7-30.74a8 8 0 0 1 8.38-4.67l22.82 3.08a8.1 8.1 0 0 1 3.12 1.11ZM116.71 95L129 88.24a7.5 7.5 0 0 0 1.5-1.07l26.91-24.33A8 8 0 0 0 159 53l-10.5-18.81A96.6 96.6 0 0 0 128 32a95.6 95.6 0 0 0-60.22 21.23L56 81.08a8 8 0 0 0-.12 5.92l11.5 30.67a8 8 0 0 0 5.81 5l2.69.58L89.2 100a8 8 0 0 1 6.94-4h16.71a7.9 7.9 0 0 0 3.86-1" opacity=".2"/><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m88 104a87.6 87.6 0 0 1-6.4 32.94l-44.7-27.49a15.9 15.9 0 0 0-6.24-2.23l-22.82-3.08a16.11 16.11 0 0 0-16 7.86h-8.72l-3.8-7.86a15.91 15.91 0 0 0-11-8.67l-8-1.73L96.14 104h16.71a16.06 16.06 0 0 0 7.73-2l12.25-6.76a16.6 16.6 0 0 0 3-2.14l26.91-24.34A15.93 15.93 0 0 0 166 49.1l-.36-.65A88.11 88.11 0 0 1 216 128m-72.69-86.66L152 56.9l-26.91 24.34L112.85 88H96.14a16 16 0 0 0-13.88 8l-8.73 15.23l-10.15-27.04l10.94-25.87a87.87 87.87 0 0 1 69-17ZM40 128a87.5 87.5 0 0 1 8.54-37.8l11.34 30.27a16 16 0 0 0 11.62 10l21.43 4.61l3.81 7.92a16.09 16.09 0 0 0 14.4 9h1.48l-7.23 16.23a16 16 0 0 0 2.86 17.37l.14.14l19.61 20.2l-1.94 10A88.11 88.11 0 0 1 40 128m102.58 86.78l1.13-5.81a16.09 16.09 0 0 0-4-13.9a2 2 0 0 1-.14-.14L120 174.74L133.7 144l22.82 3.08l45.72 28.12a88.18 88.18 0 0 1-59.66 39.58"/></g></svg></span>
<span class="lh__body">
<span class="lh__when">어제 23:18</span>
<span class="lh__where">평소와 다른 나라 · 윈도우</span>
</span>
<span class="lh__tag">확인 필요</span>
</button>
</li>
<li class="lh__item" data-ok="no" data-i="4">
<span class="lh__dot" aria-hidden="true"></span>
<button class="lh__row" type="button" aria-pressed="false">
<span class="lh__ic" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M165.66 101.66L139.31 128l26.35 26.34a8 8 0 0 1-11.32 11.32L128 139.31l-26.34 26.35a8 8 0 0 1-11.32-11.32L116.69 128l-26.35-26.34a8 8 0 0 1 11.32-11.32L128 116.69l26.34-26.35a8 8 0 0 1 11.32 11.32M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg></span>
<span class="lh__body">
<span class="lh__when">어제 22:57</span>
<span class="lh__where">서울 · 비밀번호 5회 틀림</span>
</span>
<span class="lh__tag">실패</span>
</button>
</li>
</ul>
<p class="lh__live" id="lh-live" role="status" aria-live="polite">
<span class="lh__word" data-word="a">최근 로그인 기록 4건</span>
<span class="lh__word" data-word="b">평소와 다른 나라에서 한 번 들어왔습니다</span>
</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.lh {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: $sp-2;
width: min(320px, 90vw);
padding: $sp-3;
border-radius: $r-card;
background-color: $subject-cream;
color: $subject-ink;
box-shadow: $shadow-press;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.lh__list {
position: relative;
display: flex;
flex-direction: column;
gap: $sp-2;
margin: 0;
padding: 0;
list-style: none;
}
// 세로선은 ::before 한 줄 — 점들이 이 선 위에 얹힌다
.lh__list::before {
content: "";
position: absolute;
left: 7px;
top: 8px;
bottom: 8px;
width: 2px;
border-radius: $r-pill;
background-color: $color;
}
.lh__item {
position: relative;
padding-left: $sp-6;
}
.lh__dot {
position: absolute;
left: 2px;
top: 13px;
width: 12px;
height: 12px;
border-radius: 50%;
background-color: $subject-mint;
box-shadow: 0 0 0 3px $subject-cream;
}
.lh__item[data-ok="no"] .lh__dot { background-color: $error; }
.lh__row {
position: relative;
box-sizing: border-box;
display: flex;
align-items: center;
gap: $sp-2;
width: 100%;
margin: 0;
padding: $sp-1 $sp-2;
border: 0;
border-radius: $r-sm;
background-color: $stage-paper;
color: $subject-ink;
cursor: pointer;
text-align: left;
font-family: inherit;
}
.lh__row:focus-visible { outline: 3px solid $color; outline-offset: 2px; }
.lh__row[aria-pressed="true"] {
box-shadow: inset 0 0 0 2px $color;
opacity: .62;
}
.lh__ic { display: inline-flex; flex: 0 0 auto; color: $subject-ink; }
.lh__ic svg { display: block; }
.lh__body {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-width: 0;
}
.lh__when {
font-size: 12px;
line-height: 16px;
font-weight: 700;
}
.lh__where {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 10px;
line-height: 14px;
font-weight: 500;
}
.lh__tag {
flex: 0 0 auto;
padding: 0 $sp-1;
border-radius: $r-pill;
background-color: $subject-mint;
color: $subject-ink;
white-space: nowrap;
font-size: 9px;
line-height: 16px;
font-weight: 700;
}
.lh__item[data-ok="no"] .lh__tag { background-color: $error; color: $subject-cream; }
.lh__item[data-flag="yes"] .lh__tag { background-color: $stage-paper; color: $error; }
// 평소와 다른 나라 한 줄만 테두리가 한 겹 더 붙는다 — box-shadow 로 그린다
.lh__item[data-flag="yes"] .lh__row::after {
content: "";
position: absolute;
inset: -2px;
border-radius: $r-sm;
box-shadow: 0 0 0 2px $error;
opacity: 0;
pointer-events: none;
}
// 정적 상태 — 루프를 껐을 때도 네 줄이 다 보이고 테두리도 붙어 있다
.lh:not(.is-demo) .lh__item[data-flag="yes"] .lh__row { background-color: $error-soft; }
.lh:not(.is-demo) .lh__item[data-flag="yes"] .lh__row::after { opacity: 1; }
.lh__live {
position: relative;
margin: 0;
height: 16px;
font-size: 11px;
line-height: 16px;
font-weight: 600;
color: $subject-ink;
}
.lh__word {
position: absolute;
left: 0;
right: 0;
top: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
opacity: 0;
}
.lh:not(.is-demo) .lh__word[data-word="a"] { opacity: 1; }
// ── 자동 루프: 줄들이 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); }
}
@keyframes lh-show {
0% { opacity: 0; }
1% { opacity: 1; }
100% { opacity: 1; }
}
// 네 줄이 다 선 뒤 50% 에 나머지 세 줄이 한 프레임에 가라앉고 「다른 나라」 줄만 남는다.
// 줄 하나가 떠오르는 것보다 세 줄이 동시에 바뀌는 쪽이 커야 poster 가 이 순간을 고른다 (규칙 11)
.lh.is-demo .lh__item:not([data-flag="yes"]) .lh__row,
.lh.is-demo .lh__item:not([data-flag="yes"]) .lh__dot {
animation-name: lh-dim;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes lh-dim {
0% { opacity: 1; }
50% { opacity: .34; }
100% { opacity: .34; }
}
.lh.is-demo .lh__item[data-flag="yes"] .lh__row {
animation-name: lh-alarm;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes lh-alarm {
0% { background-color: $stage-paper; }
50% { background-color: $error-soft; }
100% { background-color: $error-soft; }
}
// 그 줄만 천천히 숨쉰다
.lh.is-demo .lh__item[data-flag="yes"] .lh__row::after {
animation-name: lh-ring, lh-breathe;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $ease-out;
animation-iteration-count: infinite, infinite;
}
@keyframes lh-ring {
0% { opacity: 0; }
50% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes lh-breathe {
0% { transform: scale(1); }
50% { transform: scale(1); }
72% { transform: scale(1.045); }
90% { transform: scale(1); }
100% { transform: scale(1); }
}
.lh.is-demo .lh__word {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.lh.is-demo .lh__word[data-word="a"] { animation-name: lh-w-a; }
.lh.is-demo .lh__word[data-word="b"] { animation-name: lh-w-b; }
@keyframes lh-w-a {
0% { opacity: 1; }
50% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes lh-w-b {
0% { opacity: 0; }
50% { opacity: 1; }
100% { opacity: 1; }
}
@media (max-width: 340px) {
.lh { gap: $sp-1; padding: $sp-2; }
.lh__list { gap: $sp-1; }
.lh__item { padding-left: $sp-5; }
.lh__dot { top: 7px; width: 10px; height: 10px; }
.lh__list::before { left: 6px; top: 4px; bottom: 4px; }
.lh__row { padding: 0 $sp-1; }
.lh__when { font-size: 11px; line-height: 14px; }
.lh__where { font-size: 9px; line-height: 12px; }
.lh__live { height: 14px; font-size: 10px; line-height: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.lh.is-demo .lh__item,
.lh.is-demo .lh__item:not([data-flag="yes"]) .lh__row,
.lh.is-demo .lh__item:not([data-flag="yes"]) .lh__dot,
.lh.is-demo .lh__item[data-flag="yes"] .lh__row,
.lh.is-demo .lh__item[data-flag="yes"] .lh__row::after,
.lh.is-demo .lh__word { animation: none !important; }
.lh.is-demo .lh__item { transform: none !important; opacity: 1 !important; }
.lh.is-demo .lh__item[data-flag="yes"] .lh__row { background-color: $error-soft !important; }
.lh.is-demo .lh__item[data-flag="yes"] .lh__row::after { opacity: 1 !important; }
.lh.is-demo .lh__word[data-word="b"] { opacity: 1 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lh {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
width: min(320px, 90vw);
padding: 12px;
border-radius: 16px;
background-color: #fff7e6;
color: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.lh__list {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.lh__list::before {
content: "";
position: absolute;
left: 7px;
top: 8px;
bottom: 8px;
width: 2px;
border-radius: 999px;
background-color: #2f6df6;
}
.lh__item {
position: relative;
padding-left: 24px;
}
.lh__dot {
position: absolute;
left: 2px;
top: 13px;
width: 12px;
height: 12px;
border-radius: 50%;
background-color: #4cd4a6;
box-shadow: 0 0 0 3px #fff7e6;
}
.lh__item[data-ok=no] .lh__dot {
background-color: #c2361a;
}
.lh__row {
position: relative;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
margin: 0;
padding: 4px 8px;
border: 0;
border-radius: 8px;
background-color: #ffffff;
color: #17141a;
cursor: pointer;
text-align: left;
font-family: inherit;
}
.lh__row:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.lh__row[aria-pressed=true] {
box-shadow: inset 0 0 0 2px #2f6df6;
opacity: 0.62;
}
.lh__ic {
display: inline-flex;
flex: 0 0 auto;
color: #17141a;
}
.lh__ic svg {
display: block;
}
.lh__body {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-width: 0;
}
.lh__when {
font-size: 12px;
line-height: 16px;
font-weight: 700;
}
.lh__where {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 10px;
line-height: 14px;
font-weight: 500;
}
.lh__tag {
flex: 0 0 auto;
padding: 0 4px;
border-radius: 999px;
background-color: #4cd4a6;
color: #17141a;
white-space: nowrap;
font-size: 9px;
line-height: 16px;
font-weight: 700;
}
.lh__item[data-ok=no] .lh__tag {
background-color: #c2361a;
color: #fff7e6;
}
.lh__item[data-flag=yes] .lh__tag {
background-color: #ffffff;
color: #c2361a;
}
.lh__item[data-flag=yes] .lh__row::after {
content: "";
position: absolute;
inset: -2px;
border-radius: 8px;
box-shadow: 0 0 0 2px #c2361a;
opacity: 0;
pointer-events: none;
}
.lh:not(.is-demo) .lh__item[data-flag=yes] .lh__row {
background-color: #ffe0d8;
}
.lh:not(.is-demo) .lh__item[data-flag=yes] .lh__row::after {
opacity: 1;
}
.lh__live {
position: relative;
margin: 0;
height: 16px;
font-size: 11px;
line-height: 16px;
font-weight: 600;
color: #17141a;
}
.lh__word {
position: absolute;
left: 0;
right: 0;
top: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
opacity: 0;
}
.lh:not(.is-demo) .lh__word[data-word=a] {
opacity: 1;
}
.lh.is-demo .lh__item {
animation-name: lh-rise, lh-show;
animation-duration: 2s, 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1), 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;
}
@keyframes lh-rise {
0% {
transform: translateY(16px);
}
22% {
transform: translateY(0);
}
100% {
transform: translateY(0);
}
}
@keyframes lh-show {
0% {
opacity: 0;
}
1% {
opacity: 1;
}
100% {
opacity: 1;
}
}
.lh.is-demo .lh__item:not([data-flag=yes]) .lh__row,
.lh.is-demo .lh__item:not([data-flag=yes]) .lh__dot {
animation-name: lh-dim;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes lh-dim {
0% {
opacity: 1;
}
50% {
opacity: 0.34;
}
100% {
opacity: 0.34;
}
}
.lh.is-demo .lh__item[data-flag=yes] .lh__row {
animation-name: lh-alarm;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes lh-alarm {
0% {
background-color: #ffffff;
}
50% {
background-color: #ffe0d8;
}
100% {
background-color: #ffe0d8;
}
}
.lh.is-demo .lh__item[data-flag=yes] .lh__row::after {
animation-name: lh-ring, lh-breathe;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), ease-out;
animation-iteration-count: infinite, infinite;
}
@keyframes lh-ring {
0% {
opacity: 0;
}
50% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes lh-breathe {
0% {
transform: scale(1);
}
50% {
transform: scale(1);
}
72% {
transform: scale(1.045);
}
90% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
.lh.is-demo .lh__word {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.lh.is-demo .lh__word[data-word=a] {
animation-name: lh-w-a;
}
.lh.is-demo .lh__word[data-word=b] {
animation-name: lh-w-b;
}
@keyframes lh-w-a {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes lh-w-b {
0% {
opacity: 0;
}
50% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@media (max-width: 340px) {
.lh {
gap: 4px;
padding: 8px;
}
.lh__list {
gap: 4px;
}
.lh__item {
padding-left: 20px;
}
.lh__dot {
top: 7px;
width: 10px;
height: 10px;
}
.lh__list::before {
left: 6px;
top: 4px;
bottom: 4px;
}
.lh__row {
padding: 0 4px;
}
.lh__when {
font-size: 11px;
line-height: 14px;
}
.lh__where {
font-size: 9px;
line-height: 12px;
}
.lh__live {
height: 14px;
font-size: 10px;
line-height: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.lh.is-demo .lh__item,
.lh.is-demo .lh__item:not([data-flag=yes]) .lh__row,
.lh.is-demo .lh__item:not([data-flag=yes]) .lh__dot,
.lh.is-demo .lh__item[data-flag=yes] .lh__row,
.lh.is-demo .lh__item[data-flag=yes] .lh__row::after,
.lh.is-demo .lh__word {
animation: none !important;
}
.lh.is-demo .lh__item {
transform: none !important;
opacity: 1 !important;
}
.lh.is-demo .lh__item[data-flag=yes] .lh__row {
background-color: #ffe0d8 !important;
}
.lh.is-demo .lh__item[data-flag=yes] .lh__row::after {
opacity: 1 !important;
}
.lh.is-demo .lh__word[data-word=b] {
opacity: 1 !important;
}
}// 실제 동작: 줄을 누르면 「확인함」 표시가 켜지고 알림 영역이 그 줄을 읽어 준다. 키보드로도 된다
var root = document.querySelector('.lh');
var live = document.getElementById('lh-live');
var rows = document.querySelectorAll('.lh__row');
Array.prototype.forEach.call(rows, function (row) {
row.addEventListener('click', function () {
root.classList.remove('is-demo'); // 사람이 만졌으니 자동 루프를 끈다
var isOn = row.getAttribute('aria-pressed') === 'true';
row.setAttribute('aria-pressed', String(!isOn));
var when = row.querySelector('.lh__when').textContent;
live.textContent = isOn ? when + ' 기록의 확인 표시를 지웠습니다' : when + ' 기록을 확인함으로 표시했습니다';
});
});import { useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import styles from "./LoginHistoryTimeline.module.scss";
export type LoginEntry = {
when: string;
where: string;
tag: string;
ok: boolean;
flag?: boolean;
icon: "mobile" | "laptop" | "globe" | "fail";
};
export type LoginHistoryTimelineProps = {
entries?: LoginEntry[];
summary?: string;
alert?: string;
onMark?: (when: string, marked: boolean) => string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const ICONS: Record<LoginEntry["icon"], ReactNode> = {
mobile: (
<g fill="currentColor">
<path d="M192 56v144H64V56Z" opacity=".2" />
<path d="M176 16H80a24 24 0 0 0-24 24v176a24 24 0 0 0 24 24h96a24 24 0 0 0 24-24V40a24 24 0 0 0-24-24M72 64h112v128H72Zm8-32h96a8 8 0 0 1 8 8v8H72v-8a8 8 0 0 1 8-8m96 192H80a8 8 0 0 1-8-8v-8h112v8a8 8 0 0 1-8 8" />
</g>
),
laptop: (
<g fill="currentColor">
<path d="M216 72v104H40V72a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2" />
<path d="M232 168h-8V72a24 24 0 0 0-24-24H56a24 24 0 0 0-24 24v96h-8a8 8 0 0 0-8 8v16a24 24 0 0 0 24 24h176a24 24 0 0 0 24-24v-16a8 8 0 0 0-8-8M48 72a8 8 0 0 1 8-8h144a8 8 0 0 1 8 8v96H48Zm176 120a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-8h192ZM152 88a8 8 0 0 1-8 8h-32a8 8 0 0 1 0-16h32a8 8 0 0 1 8 8" />
</g>
),
globe: (
<g fill="currentColor">
<path d="M213.09 172.48a96 96 0 0 1-80.41 51.41l3.17-16.44a8 8 0 0 0-2-6.95l-19.74-20.33a8 8 0 0 1-1.44-8.69l13.7-30.74a8 8 0 0 1 8.38-4.67l22.82 3.08a8.1 8.1 0 0 1 3.12 1.11ZM116.71 95L129 88.24a7.5 7.5 0 0 0 1.5-1.07l26.91-24.33A8 8 0 0 0 159 53l-10.5-18.81A96.6 96.6 0 0 0 128 32a95.6 95.6 0 0 0-60.22 21.23L56 81.08a8 8 0 0 0-.12 5.92l11.5 30.67a8 8 0 0 0 5.81 5l2.69.58L89.2 100a8 8 0 0 1 6.94-4h16.71a7.9 7.9 0 0 0 3.86-1" opacity=".2" />
<path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m88 104a87.6 87.6 0 0 1-6.4 32.94l-44.7-27.49a15.9 15.9 0 0 0-6.24-2.23l-22.82-3.08a16.11 16.11 0 0 0-16 7.86h-8.72l-3.8-7.86a15.91 15.91 0 0 0-11-8.67l-8-1.73L96.14 104h16.71a16.06 16.06 0 0 0 7.73-2l12.25-6.76a16.6 16.6 0 0 0 3-2.14l26.91-24.34A15.93 15.93 0 0 0 166 49.1l-.36-.65A88.11 88.11 0 0 1 216 128m-72.69-86.66L152 56.9l-26.91 24.34L112.85 88H96.14a16 16 0 0 0-13.88 8l-8.73 15.23l-10.15-27.04l10.94-25.87a87.87 87.87 0 0 1 69-17ZM40 128a87.5 87.5 0 0 1 8.54-37.8l11.34 30.27a16 16 0 0 0 11.62 10l21.43 4.61l3.81 7.92a16.09 16.09 0 0 0 14.4 9h1.48l-7.23 16.23a16 16 0 0 0 2.86 17.37l.14.14l19.61 20.2l-1.94 10A88.11 88.11 0 0 1 40 128m102.58 86.78l1.13-5.81a16.09 16.09 0 0 0-4-13.9a2 2 0 0 1-.14-.14L120 174.74L133.7 144l22.82 3.08l45.72 28.12a88.18 88.18 0 0 1-59.66 39.58" />
</g>
),
fail: (
<g fill="currentColor">
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M165.66 101.66L139.31 128l26.35 26.34a8 8 0 0 1-11.32 11.32L128 139.31l-26.34 26.35a8 8 0 0 1-11.32-11.32L116.69 128l-26.35-26.34a8 8 0 0 1 11.32-11.32L128 116.69l26.34-26.35a8 8 0 0 1 11.32 11.32M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" />
</g>
),
};
const DEFAULT_ENTRIES: LoginEntry[] = [
{ when: "Today 09:41", where: "Seoul · Chrome · Android", tag: "OK", ok: true, icon: "mobile" },
{ when: "Today 08:02", where: "Daejeon · Safari · Mac", tag: "OK", ok: true, icon: "laptop" },
{ when: "Yesterday 23:18", where: "Unusual country · Windows", tag: "Check", ok: true, flag: true, icon: "globe" },
{ when: "Yesterday 22:57", where: "Seoul · 5 wrong passwords", tag: "Failed", ok: false, icon: "fail" },
];
export default function LoginHistoryTimeline({
entries = DEFAULT_ENTRIES,
summary = "4 recent sign-ins",
alert = "One sign-in came from an unusual country",
onMark = (when: string, marked: boolean) =>
marked ? `${when} marked as reviewed` : `${when} review mark cleared`,
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: LoginHistoryTimelineProps) {
const [demo, setDemo] = useState(true);
const [marked, setMarked] = useState<number[]>([]);
const [live, setLive] = useState("");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 줄을 누르면 「확인함」 표시가 켜지고 알림 영역이 그 줄을 읽어 준다. 키보드로도 된다
function toggleMark(i: number) {
setDemo(false);
const isOn = marked.indexOf(i) >= 0;
setMarked(isOn ? marked.filter((n) => n !== i) : marked.concat([i]));
setLive(onMark(entries[i].when, !isOn));
}
// 떠오르는 차례는 DOM 순서가 아니라 지연값이 정한다 — 눈에 띄어야 할 줄이 마지막
const delayOf = (e: LoginEntry, i: number) => (e.flag ? 520 : [40, 200, 360, 360][i] || 360);
return (
<section className={`${styles.lh} ${demo ? styles.isDemo : ""}`} style={vars}>
<ul className={styles.list}>
{entries.map((e, i) => (
<li
className={styles.item}
key={e.when}
data-ok={e.ok ? "yes" : "no"}
data-flag={e.flag ? "yes" : undefined}
style={{ animationDelay: `${delayOf(e, i)}ms, ${delayOf(e, i)}ms` }}
>
<span className={styles.dot} aria-hidden="true" />
<button
className={styles.row}
type="button"
aria-pressed={marked.indexOf(i) >= 0}
onClick={() => toggleMark(i)}
>
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="14" height="14">{ICONS[e.icon]}</svg>
</span>
<span className={styles.body}>
<span className={styles.when}>{e.when}</span>
<span className={styles.where}>{e.where}</span>
</span>
<span className={styles.tag}>{e.tag}</span>
</button>
</li>
))}
</ul>
<p className={styles.live} role="status" aria-live="polite">
{live ? (
live
) : (
<>
<span className={styles.word} data-word="a">{summary}</span>
<span className={styles.word} data-word="b">{alert}</span>
</>
)}
</p>
</section>
);
}08 성공과 실패가 함께 놓인 로그인 기록 — 측정 누적면적 36.643% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 계정 보안 설정 UI 9종 — 로그인한 뒤의 화면
이 데모가 속한 글
계정 보안 설정 UI 9종 — 로그인한 뒤의 화면계정 보안 설정은 로그인을 통과한 사람이 마주하는, 접속 중인 기기와 2단계 인증, 비밀번호를 직접 만지는 화면입니다. 아홉 개는 전부 실제로 돌아갑니다. 기기 줄은 정말로 접히고, 백업 코드는 정말로 클립보드에 들 이 글의 데모 전부 보기

