05三つの欄がそろって開くパスワード変更
いまのパスワード、新しいパスワード、確認欄がすべて条件を満たすまで保存は開きません。新しい欄の下の四つの目盛りはグリッドで切ってあるので幅がずれず、確認欄が違うとその行だけ赤く残り、理由が添えられます。
使いどころ設定画面で今のパスワードを変えるフォーム
grid-template-columnsaria-describedbyaria-disabled

<main class="stage">
<section class="pw is-demo" data-strength="0" data-msg="idle" data-bad="false">
<div class="pw__card">
<div class="pw__row">
<div class="pw__box">
<label class="pw__lab" for="pw-cur">現在のパスワード</label>
<div class="pw__field">
<input class="pw__in" id="pw-cur" type="password" autocomplete="current-password">
<span class="pw__ghost" data-g="cur" aria-hidden="true">••••••••</span>
</div>
</div>
</div>
<div class="pw__row">
<div class="pw__box">
<label class="pw__lab" for="pw-new">新しいパスワード</label>
<div class="pw__field">
<input class="pw__in" id="pw-new" type="password" autocomplete="new-password">
<span class="pw__ghost" data-g="new" aria-hidden="true">••••••••••••</span>
</div>
</div>
<div class="pw__meter" aria-hidden="true">
<span class="pw__cell"><i class="pw__fill" data-n="1"></i></span>
<span class="pw__cell"><i class="pw__fill" data-n="2"></i></span>
<span class="pw__cell"><i class="pw__fill" data-n="3"></i></span>
<span class="pw__cell"><i class="pw__fill" data-n="4"></i></span>
</div>
</div>
<div class="pw__row">
<div class="pw__box" data-box="confirm">
<label class="pw__lab" for="pw-confirm">新しいパスワード(確認)</label>
<div class="pw__field">
<input class="pw__in" id="pw-confirm" type="password" autocomplete="new-password" aria-describedby="pw-hint">
<span class="pw__ghost" data-g="confirm" aria-hidden="true">••••••••••••</span>
</div>
</div>
</div>
<p class="pw__hint" id="pw-hint" role="status" aria-live="polite">
<span class="pw__msg" data-msg="idle">8文字以上、数字と記号を混ぜます</span>
<span class="pw__msg" data-msg="bad">確認欄が新しいパスワードと違います</span>
<span class="pw__msg" data-msg="ok">二つの欄が一致します</span>
<span class="pw__msg" data-msg="saved">パスワードを変更しました</span>
</p>
<button class="pw__save" id="pw-save" type="button" aria-disabled="true">パスワードを変更</button>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.pw {
box-sizing: border-box;
width: min(320px, 90vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
.pw__card {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: $sp-2;
padding: $sp-3;
border-radius: $r-card;
background-color: $subject-cream;
box-shadow: $shadow-raised;
}
.pw__row { display: block; }
.pw__box {
box-sizing: border-box;
padding: $sp-1 $sp-3;
border: 1px solid rgba($subject-ink, .16);
border-radius: $r-sm;
background-color: $stage-paper;
transition: border-color $duration $easing;
}
.pw__box:focus-within { border-color: $color; }
.pw__lab {
display: block;
font-size: 10px;
line-height: 12px;
font-weight: 700;
letter-spacing: .02em;
color: rgba($subject-ink, .72); // AA 4.5 (blog-design)
}
.pw__field {
position: relative;
height: 20px;
}
.pw__in {
box-sizing: border-box;
margin: 0;
padding: 0;
width: 100%;
height: 20px;
border: 0;
border-radius: 0;
background-color: transparent;
color: $subject-ink;
font-family: inherit;
font-size: 13px;
line-height: 20px;
font-weight: 600;
letter-spacing: .1em;
}
.pw__in:focus { outline: none; }
.pw__box:focus-within { box-shadow: 0 0 0 3px rgba($subject-blue, .2); }
// 자동 루프에서만 보이는 「쳐 넣는 글자」 — 사람이 치기 시작하면 사라진다
.pw__ghost {
position: absolute;
left: 0;
top: 0;
display: block;
width: 100%;
font-size: 13px;
line-height: 20px;
font-weight: 700;
letter-spacing: .14em;
color: $subject-ink;
white-space: nowrap;
clip-path: inset(0 100% 0 0);
}
.pw:not(.is-demo) .pw__ghost { display: none; }
// ── 네 칸 강도 눈금 — 칸 폭은 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);
}
.pw__fill {
display: block;
width: 100%;
height: 100%;
border-radius: $r-pill;
background-color: $color;
transform: scaleX(0);
transform-origin: left center; // 규칙8: 좌변서 차오름
}
.pw__fill[data-n="4"] { background-color: $subject-mint; }
.pw:not(.is-demo)[data-strength="1"] .pw__cell:nth-child(-n + 1) .pw__fill,
.pw:not(.is-demo)[data-strength="2"] .pw__cell:nth-child(-n + 2) .pw__fill,
.pw:not(.is-demo)[data-strength="3"] .pw__cell:nth-child(-n + 3) .pw__fill,
.pw:not(.is-demo)[data-strength="4"] .pw__cell:nth-child(-n + 4) .pw__fill { transform: scaleX(1); }
// ── 이유 문구 (aria-describedby 가 가리키는 줄) ──
.pw__hint {
position: relative;
margin: 0;
height: 16px;
}
.pw__msg {
position: absolute;
left: 0;
right: 0;
top: 0;
font-size: 11px;
line-height: 16px;
font-weight: 600;
color: rgba($subject-ink, .6);
white-space: nowrap;
opacity: 0;
}
.pw__msg[data-msg="bad"] { color: $error; }
.pw__msg[data-msg="ok"],
.pw__msg[data-msg="saved"] { color: $color; }
// ── 저장 ──
.pw__save {
box-sizing: border-box;
margin: 0;
padding: 0;
width: 100%;
height: 34px;
border: 0;
border-radius: $r-control;
background-color: rgba($subject-ink, .14);
color: rgba($subject-ink, .8); // AA 4.5 (.45 는 2.76:1 이었다)
font-family: inherit;
font-size: 13px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
transition: background-color $duration $easing, color $duration $easing;
}
.pw__save:focus-visible { outline: 3px solid $color; outline-offset: 3px; }
// ── 사람이 조작한 정적 상태 — 루프를 껐을 때도 화면이 비지 않는다 ──
.pw:not(.is-demo)[data-msg="idle"] .pw__msg[data-msg="idle"],
.pw:not(.is-demo)[data-msg="bad"] .pw__msg[data-msg="bad"],
.pw:not(.is-demo)[data-msg="ok"] .pw__msg[data-msg="ok"],
.pw:not(.is-demo)[data-msg="saved"] .pw__msg[data-msg="saved"] { opacity: 1; }
.pw:not(.is-demo)[data-bad="true"] .pw__box[data-box="confirm"] {
border-color: $error;
background-color: $error-soft;
}
.pw__save[aria-disabled="false"] { background-color: $color; color: $subject-cream; }
// ── 자동 루프 (2s): 현재 → 새 비밀번호 + 눈금 → 확인 어긋남 → 맞춤 → 저장 열림(79.17%) ──
.pw.is-demo .pw__ghost {
animation-duration: $dur-loop;
animation-iteration-count: infinite;
animation-timing-function: steps(10, end);
}
.pw.is-demo .pw__ghost[data-g="cur"] { animation-name: pw-type-cur; }
.pw.is-demo .pw__ghost[data-g="new"] { animation-name: pw-type-new; }
.pw.is-demo .pw__ghost[data-g="confirm"] { animation-name: pw-type-confirm; }
@keyframes pw-type-cur {
0% { clip-path: inset(0 100% 0 0); }
12.5% { clip-path: inset(0 0 0 0); }
100% { clip-path: inset(0 0 0 0); }
}
@keyframes pw-type-new {
0% { clip-path: inset(0 100% 0 0); }
16.67% { clip-path: inset(0 100% 0 0); }
39.58% { clip-path: inset(0 0 0 0); }
100% { clip-path: inset(0 0 0 0); }
}
@keyframes pw-type-confirm {
0% { clip-path: inset(0 100% 0 0); }
45.83% { clip-path: inset(0 100% 0 0); }
54.17% { clip-path: inset(0 46% 0 0); }
66.67% { clip-path: inset(0 46% 0 0); }
79.17% { clip-path: inset(0 0 0 0); }
100% { clip-path: inset(0 0 0 0); }
}
// 눈금은 한 칸씩 찬다 — 칸마다 자기 시각을 가진 keyframes (animation 단축은 delay 를 지운다)
.pw.is-demo .pw__fill {
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.pw.is-demo .pw__fill[data-n="1"] { animation-name: pw-fill-1; }
.pw.is-demo .pw__fill[data-n="2"] { animation-name: pw-fill-2; }
.pw.is-demo .pw__fill[data-n="3"] { animation-name: pw-fill-3; }
.pw.is-demo .pw__fill[data-n="4"] { animation-name: pw-fill-4; }
@keyframes pw-fill-1 {
0% { transform: scaleX(0); }
18.75% { transform: scaleX(0); }
25% { transform: scaleX(1); }
100% { transform: scaleX(1); }
}
@keyframes pw-fill-2 {
0% { transform: scaleX(0); }
25% { transform: scaleX(0); }
31.25% { transform: scaleX(1); }
100% { transform: scaleX(1); }
}
@keyframes pw-fill-3 {
0% { transform: scaleX(0); }
31.25% { transform: scaleX(0); }
37.5% { transform: scaleX(1); }
100% { transform: scaleX(1); }
}
@keyframes pw-fill-4 {
0% { transform: scaleX(0); }
37.5% { transform: scaleX(0); }
43.75% { transform: scaleX(1); }
100% { transform: scaleX(1); }
}
// 확인 칸은 어긋난 동안만 붉게 남는다
.pw.is-demo .pw__box[data-box="confirm"] {
animation-name: pw-confirm-skin;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes pw-confirm-skin {
0% { border-color: rgba($subject-ink, .16); background-color: $stage-paper; }
54.17% { border-color: $error; background-color: $error-soft; }
79.17% { border-color: $color; background-color: $stage-paper; }
100% { border-color: $color; background-color: $stage-paper; }
}
// 이유 문구도 한 프레임에 갈린다
.pw.is-demo .pw__msg {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pw.is-demo .pw__msg[data-msg="idle"] { animation-name: pw-msg-idle; }
.pw.is-demo .pw__msg[data-msg="bad"] { animation-name: pw-msg-bad; }
.pw.is-demo .pw__msg[data-msg="ok"] { animation-name: pw-msg-ok; }
.pw.is-demo .pw__msg[data-msg="saved"] { animation-name: pw-msg-none; }
@keyframes pw-msg-idle {
0% { opacity: 1; }
54.17% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes pw-msg-bad {
0% { opacity: 0; }
54.17% { opacity: 1; }
79.17% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes pw-msg-ok {
0% { opacity: 0; }
79.17% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes pw-msg-none {
0% { opacity: 0; }
100% { opacity: 0; }
}
// 저장이 열리는 순간이 이 루프의 최대 변화 — 색은 컷으로, 반동은 부드럽게
.pw.is-demo .pw__save {
animation-name: pw-save-skin, pw-save-pop;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $ease-pop;
animation-iteration-count: infinite, infinite;
}
@keyframes pw-save-skin {
0% { background-color: rgba($subject-ink, .14); color: rgba($subject-ink, .8); }
79.17% { background-color: $color; color: $subject-cream; }
100% { background-color: $color; color: $subject-cream; }
}
@keyframes pw-save-pop {
0% { transform: scale(1); }
79.17% { transform: scale(.96); }
83.33% { transform: scale(1.04); }
89.58% { transform: scale(1); }
100% { transform: scale(1); }
}
@media (max-width: 340px) {
.pw__card { gap: $sp-1; padding: $sp-2; }
.pw__box { padding: 0 $sp-2; }
.pw__lab { font-size: 9px; line-height: 12px; }
.pw__field { height: 18px; }
.pw__in { height: 18px; font-size: 11px; line-height: 18px; }
.pw__ghost { font-size: 11px; line-height: 18px; }
.pw__cell { height: 4px; }
.pw__hint { height: 12px; }
.pw__msg { font-size: 9px; line-height: 12px; }
.pw__save { height: 24px; font-size: 11px; line-height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.pw.is-demo .pw__ghost,
.pw.is-demo .pw__fill,
.pw.is-demo .pw__box[data-box="confirm"],
.pw.is-demo .pw__msg,
.pw.is-demo .pw__save { animation: none !important; }
.pw.is-demo .pw__ghost { clip-path: inset(0 0 0 0) !important; }
.pw.is-demo .pw__fill[data-n="1"],
.pw.is-demo .pw__fill[data-n="2"],
.pw.is-demo .pw__fill[data-n="3"],
.pw.is-demo .pw__fill[data-n="4"] { transform: scaleX(1) !important; }
.pw.is-demo .pw__msg[data-msg="ok"] { opacity: 1 !important; }
.pw.is-demo .pw__save { background-color: $color !important; color: $subject-cream !important; }
.pw__box,
.pw__save { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pw {
box-sizing: border-box;
width: min(320px, 90vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.pw__card {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border-radius: 16px;
background-color: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.pw__row {
display: block;
}
.pw__box {
box-sizing: border-box;
padding: 4px 12px;
border: 1px solid rgba(23, 20, 26, 0.16);
border-radius: 8px;
background-color: #ffffff;
transition: border-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pw__box:focus-within {
border-color: #2f6df6;
}
.pw__lab {
display: block;
font-size: 10px;
line-height: 12px;
font-weight: 700;
letter-spacing: 0.02em;
color: rgba(23, 20, 26, 0.72);
}
.pw__field {
position: relative;
height: 20px;
}
.pw__in {
box-sizing: border-box;
margin: 0;
padding: 0;
width: 100%;
height: 20px;
border: 0;
border-radius: 0;
background-color: transparent;
color: #17141a;
font-family: inherit;
font-size: 13px;
line-height: 20px;
font-weight: 600;
letter-spacing: 0.1em;
}
.pw__in:focus {
outline: none;
}
.pw__box:focus-within {
box-shadow: 0 0 0 3px rgba(47, 109, 246, 0.2);
}
.pw__ghost {
position: absolute;
left: 0;
top: 0;
display: block;
width: 100%;
font-size: 13px;
line-height: 20px;
font-weight: 700;
letter-spacing: 0.14em;
color: #17141a;
white-space: nowrap;
clip-path: inset(0 100% 0 0);
}
.pw:not(.is-demo) .pw__ghost {
display: none;
}
.pw__meter {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
margin-top: 4px;
}
.pw__cell {
display: block;
overflow: hidden;
height: 6px;
border-radius: 999px;
background-color: rgba(23, 20, 26, 0.14);
}
.pw__fill {
display: block;
width: 100%;
height: 100%;
border-radius: 999px;
background-color: #2f6df6;
transform: scaleX(0);
transform-origin: left center;
}
.pw__fill[data-n="4"] {
background-color: #4cd4a6;
}
.pw:not(.is-demo)[data-strength="1"] .pw__cell:nth-child(-n+1) .pw__fill,
.pw:not(.is-demo)[data-strength="2"] .pw__cell:nth-child(-n+2) .pw__fill,
.pw:not(.is-demo)[data-strength="3"] .pw__cell:nth-child(-n+3) .pw__fill,
.pw:not(.is-demo)[data-strength="4"] .pw__cell:nth-child(-n+4) .pw__fill {
transform: scaleX(1);
}
.pw__hint {
position: relative;
margin: 0;
height: 16px;
}
.pw__msg {
position: absolute;
left: 0;
right: 0;
top: 0;
font-size: 11px;
line-height: 16px;
font-weight: 600;
color: rgba(23, 20, 26, 0.6);
white-space: nowrap;
opacity: 0;
}
.pw__msg[data-msg=bad] {
color: #c2361a;
}
.pw__msg[data-msg=ok],
.pw__msg[data-msg=saved] {
color: #2f6df6;
}
.pw__save {
box-sizing: border-box;
margin: 0;
padding: 0;
width: 100%;
height: 34px;
border: 0;
border-radius: 12px;
background-color: rgba(23, 20, 26, 0.14);
color: rgba(23, 20, 26, 0.8);
font-family: inherit;
font-size: 13px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
transition: background-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1), color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pw__save:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 3px;
}
.pw:not(.is-demo)[data-msg=idle] .pw__msg[data-msg=idle],
.pw:not(.is-demo)[data-msg=bad] .pw__msg[data-msg=bad],
.pw:not(.is-demo)[data-msg=ok] .pw__msg[data-msg=ok],
.pw:not(.is-demo)[data-msg=saved] .pw__msg[data-msg=saved] {
opacity: 1;
}
.pw:not(.is-demo)[data-bad=true] .pw__box[data-box=confirm] {
border-color: #c2361a;
background-color: #ffe0d8;
}
.pw__save[aria-disabled=false] {
background-color: #2f6df6;
color: #fff7e6;
}
.pw.is-demo .pw__ghost {
animation-duration: 2s;
animation-iteration-count: infinite;
animation-timing-function: steps(10, end);
}
.pw.is-demo .pw__ghost[data-g=cur] {
animation-name: pw-type-cur;
}
.pw.is-demo .pw__ghost[data-g=new] {
animation-name: pw-type-new;
}
.pw.is-demo .pw__ghost[data-g=confirm] {
animation-name: pw-type-confirm;
}
@keyframes pw-type-cur {
0% {
clip-path: inset(0 100% 0 0);
}
12.5% {
clip-path: inset(0 0 0 0);
}
100% {
clip-path: inset(0 0 0 0);
}
}
@keyframes pw-type-new {
0% {
clip-path: inset(0 100% 0 0);
}
16.67% {
clip-path: inset(0 100% 0 0);
}
39.58% {
clip-path: inset(0 0 0 0);
}
100% {
clip-path: inset(0 0 0 0);
}
}
@keyframes pw-type-confirm {
0% {
clip-path: inset(0 100% 0 0);
}
45.83% {
clip-path: inset(0 100% 0 0);
}
54.17% {
clip-path: inset(0 46% 0 0);
}
66.67% {
clip-path: inset(0 46% 0 0);
}
79.17% {
clip-path: inset(0 0 0 0);
}
100% {
clip-path: inset(0 0 0 0);
}
}
.pw.is-demo .pw__fill {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.pw.is-demo .pw__fill[data-n="1"] {
animation-name: pw-fill-1;
}
.pw.is-demo .pw__fill[data-n="2"] {
animation-name: pw-fill-2;
}
.pw.is-demo .pw__fill[data-n="3"] {
animation-name: pw-fill-3;
}
.pw.is-demo .pw__fill[data-n="4"] {
animation-name: pw-fill-4;
}
@keyframes pw-fill-1 {
0% {
transform: scaleX(0);
}
18.75% {
transform: scaleX(0);
}
25% {
transform: scaleX(1);
}
100% {
transform: scaleX(1);
}
}
@keyframes pw-fill-2 {
0% {
transform: scaleX(0);
}
25% {
transform: scaleX(0);
}
31.25% {
transform: scaleX(1);
}
100% {
transform: scaleX(1);
}
}
@keyframes pw-fill-3 {
0% {
transform: scaleX(0);
}
31.25% {
transform: scaleX(0);
}
37.5% {
transform: scaleX(1);
}
100% {
transform: scaleX(1);
}
}
@keyframes pw-fill-4 {
0% {
transform: scaleX(0);
}
37.5% {
transform: scaleX(0);
}
43.75% {
transform: scaleX(1);
}
100% {
transform: scaleX(1);
}
}
.pw.is-demo .pw__box[data-box=confirm] {
animation-name: pw-confirm-skin;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes pw-confirm-skin {
0% {
border-color: rgba(23, 20, 26, 0.16);
background-color: #ffffff;
}
54.17% {
border-color: #c2361a;
background-color: #ffe0d8;
}
79.17% {
border-color: #2f6df6;
background-color: #ffffff;
}
100% {
border-color: #2f6df6;
background-color: #ffffff;
}
}
.pw.is-demo .pw__msg {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pw.is-demo .pw__msg[data-msg=idle] {
animation-name: pw-msg-idle;
}
.pw.is-demo .pw__msg[data-msg=bad] {
animation-name: pw-msg-bad;
}
.pw.is-demo .pw__msg[data-msg=ok] {
animation-name: pw-msg-ok;
}
.pw.is-demo .pw__msg[data-msg=saved] {
animation-name: pw-msg-none;
}
@keyframes pw-msg-idle {
0% {
opacity: 1;
}
54.17% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes pw-msg-bad {
0% {
opacity: 0;
}
54.17% {
opacity: 1;
}
79.17% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes pw-msg-ok {
0% {
opacity: 0;
}
79.17% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes pw-msg-none {
0% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.pw.is-demo .pw__save {
animation-name: pw-save-skin, pw-save-pop;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite, infinite;
}
@keyframes pw-save-skin {
0% {
background-color: rgba(23, 20, 26, 0.14);
color: rgba(23, 20, 26, 0.8);
}
79.17% {
background-color: #2f6df6;
color: #fff7e6;
}
100% {
background-color: #2f6df6;
color: #fff7e6;
}
}
@keyframes pw-save-pop {
0% {
transform: scale(1);
}
79.17% {
transform: scale(0.96);
}
83.33% {
transform: scale(1.04);
}
89.58% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
@media (max-width: 340px) {
.pw__card {
gap: 4px;
padding: 8px;
}
.pw__box {
padding: 0 8px;
}
.pw__lab {
font-size: 9px;
line-height: 12px;
}
.pw__field {
height: 18px;
}
.pw__in {
height: 18px;
font-size: 11px;
line-height: 18px;
}
.pw__ghost {
font-size: 11px;
line-height: 18px;
}
.pw__cell {
height: 4px;
}
.pw__hint {
height: 12px;
}
.pw__msg {
font-size: 9px;
line-height: 12px;
}
.pw__save {
height: 24px;
font-size: 11px;
line-height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.pw.is-demo .pw__ghost,
.pw.is-demo .pw__fill,
.pw.is-demo .pw__box[data-box=confirm],
.pw.is-demo .pw__msg,
.pw.is-demo .pw__save {
animation: none !important;
}
.pw.is-demo .pw__ghost {
clip-path: inset(0 0 0 0) !important;
}
.pw.is-demo .pw__fill[data-n="1"],
.pw.is-demo .pw__fill[data-n="2"],
.pw.is-demo .pw__fill[data-n="3"],
.pw.is-demo .pw__fill[data-n="4"] {
transform: scaleX(1) !important;
}
.pw.is-demo .pw__msg[data-msg=ok] {
opacity: 1 !important;
}
.pw.is-demo .pw__save {
background-color: #2f6df6 !important;
color: #fff7e6 !important;
}
.pw__box,
.pw__save {
transition: none !important;
}
}// 실제 동작: 세 칸이 조건을 채울 때까지 저장은 aria-disabled 로 잠겨 있고, 눌리면 이유를 읽어 준다
var root = document.querySelector('.pw');
var cur = document.getElementById('pw-cur');
var nw = document.getElementById('pw-new');
var cf = document.getElementById('pw-confirm');
var save = document.getElementById('pw-save');
function strength(v) {
var n = 0;
if (v.length >= 8) n++;
if (/[0-9]/.test(v)) n++;
if (/[^A-Za-z0-9]/.test(v)) n++;
if (/[A-Z]/.test(v) && /[a-z]/.test(v)) n++;
return n;
}
function refresh() {
root.classList.remove('is-demo'); // 사람이 치기 시작했으니 자동 루프를 끈다
var s = strength(nw.value);
var bad = cf.value.length > 0 && cf.value !== nw.value;
var ok = cur.value.length > 0 && s >= 3 && cf.value.length > 0 && !bad;
root.setAttribute('data-strength', String(s));
root.setAttribute('data-bad', String(bad));
root.setAttribute('data-msg', bad ? 'bad' : (ok ? 'ok' : 'idle'));
save.setAttribute('aria-disabled', String(!ok));
}
[cur, nw, cf].forEach(function (el) { el.addEventListener('input', refresh); });
save.addEventListener('click', function () {
root.classList.remove('is-demo');
if (save.getAttribute('aria-disabled') === 'true') { // 진짜 disabled 가 아니라 이유를 읽어 준다
root.setAttribute('data-msg', root.getAttribute('data-bad') === 'true' ? 'bad' : 'idle');
cf.focus({ preventScroll: true });
return;
}
root.setAttribute('data-msg', 'saved');
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PasswordChangeForm.module.scss";
export type HintKey = "idle" | "bad" | "ok" | "saved";
export type PasswordChangeFormProps = {
labels?: { current: string; next: string; confirm: string; save: string };
hints?: Record<HintKey, string>;
/** 강도 눈금 네 칸 중 몇 칸이 차야 저장이 열리는가 */
need?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type InputEvt = { target: { value: string } };
type Fields = { current: string; next: string; confirm: string };
// 네 칸 눈금과 같은 규칙 — 길이 · 숫자 · 기호 · 대소문자 섞임
function strength(v: string): number {
let n = 0;
if (v.length >= 8) n += 1;
if (/[0-9]/.test(v)) n += 1;
if (/[^A-Za-z0-9]/.test(v)) n += 1;
if (/[A-Z]/.test(v) && /[a-z]/.test(v)) n += 1;
return n;
}
export default function PasswordChangeForm({
labels = {
current: "Current password",
next: "New password",
confirm: "Confirm new password",
save: "Change password",
},
hints = {
idle: "Eight characters or more, mixed with digits and symbols",
bad: "The confirm field does not match the new password",
ok: "Both fields match",
saved: "Password changed",
},
need = 3,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: PasswordChangeFormProps) {
const [demo, setDemo] = useState(true);
const [v, setV] = useState<Fields>({ current: "", next: "", confirm: "" });
const [saved, setSaved] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const score = strength(v.next);
const bad = v.confirm.length > 0 && v.confirm !== v.next;
const ok = v.current.length > 0 && score >= need && v.confirm.length > 0 && !bad;
const msg: HintKey = saved && ok ? "saved" : bad ? "bad" : ok ? "ok" : "idle";
function set(key: keyof Fields) {
return (e: InputEvt) => {
setDemo(false); // 사람이 치기 시작했으니 자동 루프를 끈다
setSaved(false);
setV((p) => ({ ...p, [key]: e.target.value }));
};
}
// 진짜 disabled 가 아니라 aria-disabled — 눌리면 왜 잠겼는지 읽어 준다
function submit() {
setDemo(false);
setSaved(ok);
}
const ghost = (n: number) => "•".repeat(n);
return (
<section
className={`${styles.pw} ${demo ? styles.isDemo : ""}`}
data-strength={String(score)}
data-msg={msg}
data-bad={String(bad)}
style={vars}
>
<div className={styles.card}>
<div className={styles.row}>
<div className={styles.box}>
<label className={styles.lab} htmlFor="pw-cur">{labels.current}</label>
<div className={styles.field}>
<input
className={styles.in}
id="pw-cur"
type="password"
autoComplete="current-password"
value={v.current}
onChange={set("current")}
/>
<span className={styles.ghost} data-g="cur" aria-hidden="true">{ghost(8)}</span>
</div>
</div>
</div>
<div className={styles.row}>
<div className={styles.box}>
<label className={styles.lab} htmlFor="pw-new">{labels.next}</label>
<div className={styles.field}>
<input
className={styles.in}
id="pw-new"
type="password"
autoComplete="new-password"
value={v.next}
onChange={set("next")}
/>
<span className={styles.ghost} data-g="new" aria-hidden="true">{ghost(12)}</span>
</div>
</div>
<div className={styles.meter} aria-hidden="true">
{[1, 2, 3, 4].map((n) => (
<span className={styles.cell} key={n}>
<i className={styles.fill} data-n={String(n)} />
</span>
))}
</div>
</div>
<div className={styles.row}>
<div className={styles.box} data-box="confirm">
<label className={styles.lab} htmlFor="pw-confirm">{labels.confirm}</label>
<div className={styles.field}>
<input
className={styles.in}
id="pw-confirm"
type="password"
autoComplete="new-password"
aria-describedby="pw-hint"
value={v.confirm}
onChange={set("confirm")}
/>
<span className={styles.ghost} data-g="confirm" aria-hidden="true">{ghost(12)}</span>
</div>
</div>
</div>
<p className={styles.hint} id="pw-hint" role="status" aria-live="polite">
<span className={styles.msg} data-msg="idle">{hints.idle}</span>
<span className={styles.msg} data-msg="bad">{hints.bad}</span>
<span className={styles.msg} data-msg="ok">{hints.ok}</span>
<span className={styles.msg} data-msg="saved">{hints.saved}</span>
</p>
<button className={styles.save} type="button" aria-disabled={!ok} onClick={submit}>
{labels.save}
</button>
</div>
</section>
);
}05 三つの欄がそろって開くパスワード変更 — 計測 累積面積 18.794% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · アカウント セキュリティ 設定 UI 9選 — ログイン後の画面
このデモが属する記事
アカウント セキュリティ 設定 UI 9選 — ログイン後の画面アカウント セキュリティ 設定とは、ログインを済ませた人が次に巡り会う画面のことです。今つながっている端末、二段階認証、パスワードそのものを、本人の手で確かめて変えていく場所です。9つすべて実際に動きます。端末の行は本当に折りたたまれ、バッ この記事のデモをすべて見る

