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">ソウル · Chrome · Android</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">大田 · Safari · Mac</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">いつもと違う国 · Windows</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 オリジナル · SCSS と React は zip に同梱 · アカウント セキュリティ 設定 UI 9選 — ログイン後の画面
このデモが属する記事
アカウント セキュリティ 設定 UI 9選 — ログイン後の画面アカウント セキュリティ 設定とは、ログインを済ませた人が次に巡り会う画面のことです。今つながっている端末、二段階認証、パスワードそのものを、本人の手で確かめて変えていく場所です。9つすべて実際に動きます。端末の行は本当に折りたたまれ、バッ この記事のデモをすべて見る

