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 as an alert-flavored dialog, so focus lands inside it first and the warning gets read out.
Best forThe last guard standing in front of an account deletion that cannot be undone
role="alertdialog"aria-disabledoutline

<main class="stage">
<section class="dz is-demo" data-open="no">
<p class="dz__head">
<span class="dz__ic" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 256 256"><g fill="currentColor"><path d="M215.46 216H40.54c-12.62 0-20.54-13.21-14.41-23.91l87.46-151.87c6.3-11 22.52-11 28.82 0l87.46 151.87c6.13 10.7-1.79 23.91-14.41 23.91" opacity=".2"/><path d="M236.8 188.09L149.35 36.22a24.76 24.76 0 0 0-42.7 0L19.2 188.09a23.51 23.51 0 0 0 0 23.72A24.35 24.35 0 0 0 40.55 224h174.9a24.35 24.35 0 0 0 21.33-12.19a23.51 23.51 0 0 0 .02-23.72m-13.87 15.71a8.5 8.5 0 0 1-7.48 4.2H40.55a8.5 8.5 0 0 1-7.48-4.2a7.59 7.59 0 0 1 0-7.72l87.45-151.87a8.75 8.75 0 0 1 15 0l87.45 151.87a7.59 7.59 0 0 1-.04 7.72M120 144v-40a8 8 0 0 1 16 0v40a8 8 0 0 1-16 0m20 36a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/></g></svg></span>
Danger zone
</p>
<p class="dz__desc">Deleting the account takes your posts and files with it. Type DELETE exactly into the field.</p>
<label class="dz__label" for="dz-input">Confirmation</label>
<span class="dz__slot">
<input class="dz__input" id="dz-input" type="text" autocomplete="off" spellcheck="false" placeholder="DELETE">
<span class="dz__ghost" aria-hidden="true"><span class="dz__typed">DELETE</span></span>
</span>
<button class="dz__btn" id="dz-del" type="button" aria-disabled="true">
<span class="dz__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="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg></span>
Delete account
</button>
<p class="dz__live" id="dz-live" role="status" aria-live="polite">
<span class="dz__word" data-word="lock">It opens only when DELETE is typed exactly</span>
<span class="dz__word" data-word="open">The delete button is now open</span>
</p>
<span class="dz__scrim" aria-hidden="true"></span>
<div class="dz__dialog" id="dz-dialog" role="alertdialog" aria-modal="true" aria-labelledby="dz-dtitle" aria-describedby="dz-dbody">
<p class="dz__dtitle" id="dz-dtitle">Delete for good?</p>
<p class="dz__dbody" id="dz-dbody">It can be undone within 30 days.</p>
<span class="dz__drow">
<button class="dz__dbtn dz__dbtn--no" id="dz-no" type="button">Cancel</button>
<button class="dz__dbtn dz__dbtn--yes" id="dz-yes" type="button">Delete</button>
</span>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-ink);
// 확인창은 position: absolute 로 카드 위에 띄운다. 카드 자체가 확인창보다 높아
// 뿌리가 이미 그 높이를 품고 있다 — 그래서 scrollHeight 가 무대를 넘지 않는다 (BD8·BD9)
.dz {
position: relative;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: $sp-2;
width: min(300px, 88vw);
padding: $sp-3;
border-radius: $r-card;
background-color: $subject-cream;
color: $subject-ink;
box-shadow: $shadow-raised;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.dz__ic { display: inline-flex; flex: 0 0 auto; }
.dz__ic svg { display: block; }
.dz__head {
display: flex;
align-items: center;
gap: $sp-1;
margin: 0;
font-size: 13px;
line-height: 18px;
font-weight: 800;
color: $error;
}
.dz__desc {
margin: 0;
font-size: 11px;
line-height: 16px;
font-weight: 500;
color: $subject-ink;
}
.dz__label {
display: block;
font-size: 10px;
line-height: 14px;
font-weight: 700;
color: $error;
}
.dz__slot { position: relative; display: block; }
.dz__input {
box-sizing: border-box;
display: block;
width: 100%;
height: 32px;
margin: 0;
padding: 0 $sp-2;
border: 0;
border-radius: $r-sm;
background-color: $stage-paper;
color: $subject-ink;
font-family: ui-monospace, "Consolas", monospace;
font-size: 14px;
line-height: 32px;
font-weight: 700;
box-shadow: inset 0 0 0 2px $error-soft;
}
.dz__input:focus-visible { outline: 3px solid $color; outline-offset: 2px; }
// 자동 루프 동안만 한 자씩 찍히는 글자를 진짜 입력칸 위에 겹친다
.dz__ghost {
position: absolute;
left: $sp-2;
top: 0;
bottom: 0;
display: none;
align-items: center;
}
.dz.is-demo .dz__ghost { display: flex; }
.dz.is-demo .dz__input { color: transparent; }
.dz.is-demo .dz__input::placeholder { color: transparent; }
.dz__typed {
display: block;
overflow: hidden;
width: 0;
white-space: nowrap;
border-right: 2px solid $error;
font-family: ui-monospace, "Consolas", monospace;
font-size: 14px;
line-height: 22px;
font-weight: 700;
color: $subject-ink;
}
.dz__btn {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
gap: $sp-1;
width: 100%;
height: 34px;
margin: 0;
padding: 0;
border: 0;
border-radius: $r-control;
background-color: $error;
color: $subject-cream;
cursor: pointer;
font-family: inherit;
font-size: 13px;
line-height: 18px;
font-weight: 800;
transition-property: background-color, color;
transition-duration: $duration;
transition-timing-function: $easing;
}
// 낱말이 정확히 맞을 때까지 버튼은 잠긴 채다
.dz__btn[aria-disabled="true"] {
background-color: $error-soft;
color: $subject-ink; // $error 글자는 4.41:1 로 미달 — 밝은 면이라 잉크로 14.68:1
cursor: default;
}
.dz__btn:focus-visible { outline: 3px solid $color; outline-offset: 3px; }
.dz__live {
position: relative;
margin: 0;
height: 16px;
font-size: 10px;
line-height: 16px;
font-weight: 600;
color: $subject-ink;
}
.dz__word {
position: absolute;
left: 0;
right: 0;
top: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
opacity: 0;
}
.dz:not(.is-demo) .dz__word[data-word="lock"] { opacity: 1; }
.dz:not(.is-demo) .dz__btn[aria-disabled="false"] ~ .dz__live .dz__word[data-word="lock"] { opacity: 0; }
.dz:not(.is-demo) .dz__btn[aria-disabled="false"] ~ .dz__live .dz__word[data-word="open"] { opacity: 1; }
.dz__scrim {
position: absolute;
inset: 0;
border-radius: $r-card;
background-color: $subject-ink;
opacity: 0;
visibility: hidden;
}
.dz__dialog {
position: absolute;
left: $sp-3;
right: $sp-3;
bottom: $sp-3;
display: flex;
flex-direction: column;
gap: $sp-1;
padding: $sp-3;
border-radius: $r-card;
background-color: $stage-paper;
color: $subject-ink;
box-shadow: $shadow-raised;
opacity: 0;
visibility: hidden;
}
.dz:not(.is-demo)[data-open="yes"] .dz__scrim { opacity: .78; visibility: visible; }
.dz:not(.is-demo)[data-open="yes"] .dz__dialog { opacity: 1; visibility: visible; }
.dz__dtitle {
margin: 0;
font-size: 13px;
line-height: 18px;
font-weight: 800;
color: $subject-ink;
}
.dz__dbody {
margin: 0;
font-size: 10px;
line-height: 14px;
font-weight: 500;
color: $subject-ink;
}
.dz__drow {
display: flex;
gap: $sp-2;
margin-top: $sp-1;
}
.dz__dbtn {
box-sizing: border-box;
flex: 1 1 auto;
height: 30px;
margin: 0;
padding: 0;
border: 0;
border-radius: $r-control;
cursor: pointer;
font-family: inherit;
font-size: 12px;
line-height: 16px;
font-weight: 700;
}
.dz__dbtn:focus-visible { outline: 3px solid $color; outline-offset: 2px; }
.dz__dbtn--no { background-color: $subject-cream; color: $subject-ink; }
.dz__dbtn--yes { background-color: $error; color: $subject-cream; }
// ── 자동 루프: 한 자씩 찍힘(0~55%) → 버튼 열림(58%) → 확인창 열림(70%) → 열린 채로 머문다 ──
.dz.is-demo .dz__typed {
animation-name: dz-type, dz-caret;
animation-duration: $dur-loop, 400ms;
animation-timing-function: steps(6, end), steps(1, end);
animation-iteration-count: infinite, infinite;
}
@keyframes dz-type {
0% { width: 0ch; }
55% { width: 6ch; }
100% { width: 6ch; }
}
@keyframes dz-caret {
0% { border-right-color: $error; }
50% { border-right-color: transparent; }
100% { border-right-color: transparent; }
}
.dz.is-demo .dz__btn {
animation-name: dz-unlock, dz-nudge;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
}
@keyframes dz-unlock {
0% { background-color: $error-soft; color: $error; }
58% { background-color: $error; color: $subject-cream; }
100% { background-color: $error; color: $subject-cream; }
}
// 컷만으로는 움직인 프레임이 안 차서 버튼이 열리는 순간 한 번 들썩이게 한다
@keyframes dz-nudge {
0% { transform: translateY(0); }
56% { transform: translateY(0); }
60% { transform: translateY(-3px); }
66% { transform: translateY(0); }
69% { transform: translateY(2px); }
76% { transform: translateY(0); }
100% { transform: translateY(0); }
}
.dz.is-demo .dz__word {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dz.is-demo .dz__word[data-word="lock"] { animation-name: dz-w-lock; }
.dz.is-demo .dz__word[data-word="open"] { animation-name: dz-w-open; }
@keyframes dz-w-lock {
0% { opacity: 1; }
58% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes dz-w-open {
0% { opacity: 0; }
58% { opacity: 1; }
100% { opacity: 1; }
}
// 확인창은 한 프레임에 컷으로 열리고 루프가 끝날 때까지 닫지 않는다 (닫히는 프레임이 poster 로 뽑힌다)
.dz.is-demo .dz__scrim {
animation-name: dz-scrim;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes dz-scrim {
0% { opacity: 0; visibility: hidden; }
70% { opacity: .78; visibility: visible; }
100% { opacity: .78; visibility: visible; }
}
.dz.is-demo .dz__dialog {
animation-name: dz-open;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes dz-open {
0% { opacity: 0; visibility: hidden; }
70% { opacity: 1; visibility: visible; }
100% { opacity: 1; visibility: visible; }
}
.dz.is-demo .dz__dbtn--yes {
animation-name: dz-yes-pulse;
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
@keyframes dz-yes-pulse {
0% { transform: scale(1); }
72% { transform: scale(1); }
82% { transform: scale(1.08); }
90% { transform: scale(1); }
100% { transform: scale(1); }
}
@media (max-width: 340px) {
.dz { gap: $sp-1; padding: $sp-2; }
.dz__head { font-size: 12px; line-height: 16px; }
.dz__desc { font-size: 10px; line-height: 13px; }
.dz__label { font-size: 9px; line-height: 12px; }
.dz__input { height: 28px; font-size: 12px; line-height: 28px; }
.dz__typed { font-size: 12px; line-height: 18px; }
.dz__btn { height: 28px; font-size: 12px; }
.dz__live { height: 14px; font-size: 9px; line-height: 14px; }
.dz__dialog { left: $sp-2; right: $sp-2; bottom: $sp-2; gap: 0; padding: $sp-2; }
.dz__dtitle { font-size: 12px; line-height: 16px; }
.dz__dbody { font-size: 9px; line-height: 12px; }
.dz__drow { margin-top: $sp-1; }
.dz__dbtn { height: 26px; font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.dz.is-demo .dz__typed,
.dz.is-demo .dz__btn,
.dz.is-demo .dz__word,
.dz.is-demo .dz__scrim,
.dz.is-demo .dz__dialog,
.dz.is-demo .dz__dbtn--yes { animation: none !important; }
.dz.is-demo .dz__typed { width: 6ch !important; }
.dz.is-demo .dz__btn { background-color: $error !important; color: $subject-cream !important; }
.dz.is-demo .dz__word[data-word="open"] { opacity: 1 !important; }
.dz.is-demo .dz__scrim { opacity: .78 !important; visibility: visible !important; }
.dz.is-demo .dz__dialog { opacity: 1 !important; visibility: visible !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);
}
.dz {
position: relative;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
width: min(300px, 88vw);
padding: 12px;
border-radius: 16px;
background-color: #fff7e6;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.dz__ic {
display: inline-flex;
flex: 0 0 auto;
}
.dz__ic svg {
display: block;
}
.dz__head {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-size: 13px;
line-height: 18px;
font-weight: 800;
color: #c2361a;
}
.dz__desc {
margin: 0;
font-size: 11px;
line-height: 16px;
font-weight: 500;
color: #17141a;
}
.dz__label {
display: block;
font-size: 10px;
line-height: 14px;
font-weight: 700;
color: #c2361a;
}
.dz__slot {
position: relative;
display: block;
}
.dz__input {
box-sizing: border-box;
display: block;
width: 100%;
height: 32px;
margin: 0;
padding: 0 8px;
border: 0;
border-radius: 8px;
background-color: #ffffff;
color: #17141a;
font-family: ui-monospace, "Consolas", monospace;
font-size: 14px;
line-height: 32px;
font-weight: 700;
box-shadow: inset 0 0 0 2px #ffe0d8;
}
.dz__input:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.dz__ghost {
position: absolute;
left: 8px;
top: 0;
bottom: 0;
display: none;
align-items: center;
}
.dz.is-demo .dz__ghost {
display: flex;
}
.dz.is-demo .dz__input {
color: transparent;
}
.dz.is-demo .dz__input::placeholder {
color: transparent;
}
.dz__typed {
display: block;
overflow: hidden;
width: 0;
white-space: nowrap;
border-right: 2px solid #c2361a;
font-family: ui-monospace, "Consolas", monospace;
font-size: 14px;
line-height: 22px;
font-weight: 700;
color: #17141a;
}
.dz__btn {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
width: 100%;
height: 34px;
margin: 0;
padding: 0;
border: 0;
border-radius: 12px;
background-color: #c2361a;
color: #fff7e6;
cursor: pointer;
font-family: inherit;
font-size: 13px;
line-height: 18px;
font-weight: 800;
transition-property: background-color, color;
transition-duration: 220ms;
transition-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
}
.dz__btn[aria-disabled=true] {
background-color: #ffe0d8;
color: #17141a;
cursor: default;
}
.dz__btn:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 3px;
}
.dz__live {
position: relative;
margin: 0;
height: 16px;
font-size: 10px;
line-height: 16px;
font-weight: 600;
color: #17141a;
}
.dz__word {
position: absolute;
left: 0;
right: 0;
top: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
opacity: 0;
}
.dz:not(.is-demo) .dz__word[data-word=lock] {
opacity: 1;
}
.dz:not(.is-demo) .dz__btn[aria-disabled=false] ~ .dz__live .dz__word[data-word=lock] {
opacity: 0;
}
.dz:not(.is-demo) .dz__btn[aria-disabled=false] ~ .dz__live .dz__word[data-word=open] {
opacity: 1;
}
.dz__scrim {
position: absolute;
inset: 0;
border-radius: 16px;
background-color: #17141a;
opacity: 0;
visibility: hidden;
}
.dz__dialog {
position: absolute;
left: 12px;
right: 12px;
bottom: 12px;
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
border-radius: 16px;
background-color: #ffffff;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
visibility: hidden;
}
.dz:not(.is-demo)[data-open=yes] .dz__scrim {
opacity: 0.78;
visibility: visible;
}
.dz:not(.is-demo)[data-open=yes] .dz__dialog {
opacity: 1;
visibility: visible;
}
.dz__dtitle {
margin: 0;
font-size: 13px;
line-height: 18px;
font-weight: 800;
color: #17141a;
}
.dz__dbody {
margin: 0;
font-size: 10px;
line-height: 14px;
font-weight: 500;
color: #17141a;
}
.dz__drow {
display: flex;
gap: 8px;
margin-top: 4px;
}
.dz__dbtn {
box-sizing: border-box;
flex: 1 1 auto;
height: 30px;
margin: 0;
padding: 0;
border: 0;
border-radius: 12px;
cursor: pointer;
font-family: inherit;
font-size: 12px;
line-height: 16px;
font-weight: 700;
}
.dz__dbtn:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.dz__dbtn--no {
background-color: #fff7e6;
color: #17141a;
}
.dz__dbtn--yes {
background-color: #c2361a;
color: #fff7e6;
}
.dz.is-demo .dz__typed {
animation-name: dz-type, dz-caret;
animation-duration: 2s, 400ms;
animation-timing-function: steps(6, end), steps(1, end);
animation-iteration-count: infinite, infinite;
}
@keyframes dz-type {
0% {
width: 0ch;
}
55% {
width: 6ch;
}
100% {
width: 6ch;
}
}
@keyframes dz-caret {
0% {
border-right-color: #c2361a;
}
50% {
border-right-color: transparent;
}
100% {
border-right-color: transparent;
}
}
.dz.is-demo .dz__btn {
animation-name: dz-unlock, dz-nudge;
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 dz-unlock {
0% {
background-color: #ffe0d8;
color: #c2361a;
}
58% {
background-color: #c2361a;
color: #fff7e6;
}
100% {
background-color: #c2361a;
color: #fff7e6;
}
}
@keyframes dz-nudge {
0% {
transform: translateY(0);
}
56% {
transform: translateY(0);
}
60% {
transform: translateY(-3px);
}
66% {
transform: translateY(0);
}
69% {
transform: translateY(2px);
}
76% {
transform: translateY(0);
}
100% {
transform: translateY(0);
}
}
.dz.is-demo .dz__word {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dz.is-demo .dz__word[data-word=lock] {
animation-name: dz-w-lock;
}
.dz.is-demo .dz__word[data-word=open] {
animation-name: dz-w-open;
}
@keyframes dz-w-lock {
0% {
opacity: 1;
}
58% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes dz-w-open {
0% {
opacity: 0;
}
58% {
opacity: 1;
}
100% {
opacity: 1;
}
}
.dz.is-demo .dz__scrim {
animation-name: dz-scrim;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes dz-scrim {
0% {
opacity: 0;
visibility: hidden;
}
70% {
opacity: 0.78;
visibility: visible;
}
100% {
opacity: 0.78;
visibility: visible;
}
}
.dz.is-demo .dz__dialog {
animation-name: dz-open;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes dz-open {
0% {
opacity: 0;
visibility: hidden;
}
70% {
opacity: 1;
visibility: visible;
}
100% {
opacity: 1;
visibility: visible;
}
}
.dz.is-demo .dz__dbtn--yes {
animation-name: dz-yes-pulse;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes dz-yes-pulse {
0% {
transform: scale(1);
}
72% {
transform: scale(1);
}
82% {
transform: scale(1.08);
}
90% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
@media (max-width: 340px) {
.dz {
gap: 4px;
padding: 8px;
}
.dz__head {
font-size: 12px;
line-height: 16px;
}
.dz__desc {
font-size: 10px;
line-height: 13px;
}
.dz__label {
font-size: 9px;
line-height: 12px;
}
.dz__input {
height: 28px;
font-size: 12px;
line-height: 28px;
}
.dz__typed {
font-size: 12px;
line-height: 18px;
}
.dz__btn {
height: 28px;
font-size: 12px;
}
.dz__live {
height: 14px;
font-size: 9px;
line-height: 14px;
}
.dz__dialog {
left: 8px;
right: 8px;
bottom: 8px;
gap: 0;
padding: 8px;
}
.dz__dtitle {
font-size: 12px;
line-height: 16px;
}
.dz__dbody {
font-size: 9px;
line-height: 12px;
}
.dz__drow {
margin-top: 4px;
}
.dz__dbtn {
height: 26px;
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.dz.is-demo .dz__typed,
.dz.is-demo .dz__btn,
.dz.is-demo .dz__word,
.dz.is-demo .dz__scrim,
.dz.is-demo .dz__dialog,
.dz.is-demo .dz__dbtn--yes {
animation: none !important;
}
.dz.is-demo .dz__typed {
width: 6ch !important;
}
.dz.is-demo .dz__btn {
background-color: #c2361a !important;
color: #fff7e6 !important;
}
.dz.is-demo .dz__word[data-word=open] {
opacity: 1 !important;
}
.dz.is-demo .dz__scrim {
opacity: 0.78 !important;
visibility: visible !important;
}
.dz.is-demo .dz__dialog {
opacity: 1 !important;
visibility: visible !important;
}
}// 실제 동작: DELETE 를 정확히 쳐야 버튼이 열리고, 열린 확인창 안에 초점이 갇힌다 (Escape 로 닫는다)
var root = document.querySelector('.dz');
var input = document.getElementById('dz-input');
var delBtn = document.getElementById('dz-del');
var dialog = document.getElementById('dz-dialog');
var noBtn = document.getElementById('dz-no');
var yesBtn = document.getElementById('dz-yes');
var live = document.getElementById('dz-live');
var word = 'DELETE';
function isLocked() { return input.value !== word; }
function openDialog(on) {
root.setAttribute('data-open', on ? 'yes' : 'no');
(on ? noBtn : delBtn).focus({ preventScroll: true });
}
input.addEventListener('input', function () {
root.classList.remove('is-demo'); // 사람이 만졌으니 자동 루프를 끈다
delBtn.setAttribute('aria-disabled', String(isLocked()));
live.textContent = isLocked() ? 'It opens only when DELETE is typed exactly' : 'The delete button is now open';
});
delBtn.addEventListener('click', function () { if (!isLocked()) openDialog(true); });
noBtn.addEventListener('click', function () { openDialog(false); });
yesBtn.addEventListener('click', function () { openDialog(false); live.textContent = 'Your delete request was sent'; });
dialog.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { openDialog(false); return; }
if (e.key !== 'Tab') return;
e.preventDefault(); // 초점이 확인창 밖으로 못 나간다
(document.activeElement === noBtn ? yesBtn : noBtn).focus({ preventScroll: true });
});import { useState, useRef, useEffect } from "react";
import type { CSSProperties } from "react";
import styles from "./DeleteAccountZone.module.scss";
export type DeleteAccountZoneProps = {
word?: string;
head?: string;
desc?: string;
label?: string;
action?: string;
logs?: { lock: string; open: string; sent: string };
dialog?: { title: string; body: string; no: string; yes: string };
duration?: number;
easing?: string;
color?: string;
};
// ambient d.ts 에 React 이벤트 제네릭이 없다 — 파일 안 로컬 타입으로 받는다
type KeyEvt = { key: string; preventDefault(): void };
type TextEvt = { target: { value: string } };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const WARNING = (
<g fill="currentColor">
<path d="M215.46 216H40.54c-12.62 0-20.54-13.21-14.41-23.91l87.46-151.87c6.3-11 22.52-11 28.82 0l87.46 151.87c6.13 10.7-1.79 23.91-14.41 23.91" opacity=".2" />
<path d="M236.8 188.09L149.35 36.22a24.76 24.76 0 0 0-42.7 0L19.2 188.09a23.51 23.51 0 0 0 0 23.72A24.35 24.35 0 0 0 40.55 224h174.9a24.35 24.35 0 0 0 21.33-12.19a23.51 23.51 0 0 0 .02-23.72m-13.87 15.71a8.5 8.5 0 0 1-7.48 4.2H40.55a8.5 8.5 0 0 1-7.48-4.2a7.59 7.59 0 0 1 0-7.72l87.45-151.87a8.75 8.75 0 0 1 15 0l87.45 151.87a7.59 7.59 0 0 1-.04 7.72M120 144v-40a8 8 0 0 1 16 0v40a8 8 0 0 1-16 0m20 36a12 12 0 1 1-12-12a12 12 0 0 1 12 12" />
</g>
);
const TRASH = (
<g fill="currentColor">
<path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2" />
<path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0" />
</g>
);
export default function DeleteAccountZone({
word = "DELETE",
head = "Danger zone",
desc = "Deleting the account removes your posts and files with it. Type DELETE exactly to confirm.",
label = "Confirm phrase",
action = "Delete account",
logs = {
lock: "Type DELETE exactly to unlock",
open: "The delete button is unlocked",
sent: "Delete request sent",
},
dialog = {
title: "Delete for real?",
body: "You can undo this within 30 days.",
no: "Cancel",
yes: "Yes, delete",
},
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: DeleteAccountZoneProps) {
const [demo, setDemo] = useState(true);
const [typed, setTyped] = useState("");
const [open, setOpen] = useState(false);
const [live, setLive] = useState("");
const noRef = useRef<HTMLButtonElement>(null);
const yesRef = useRef<HTMLButtonElement>(null);
const delRef = useRef<HTMLButtonElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const isLocked = typed !== word;
// 초점은 확인창이 열리면 안으로, 닫히면 삭제 버튼으로 돌아온다. 언제나 preventScroll
useEffect(() => {
if (!demo) {
const t = open ? noRef.current : delRef.current;
if (t !== null) t.focus({ preventScroll: true });
}
}, [open, demo]);
function handleInput(e: TextEvt) {
setDemo(false); // 사람이 만졌으니 자동 루프를 끈다
setTyped(e.target.value);
setLive(e.target.value !== word ? logs.lock : logs.open);
}
// 초점이 확인창 밖으로 못 나간다
function trapFocus(e: KeyEvt) {
if (e.key === "Escape") {
setOpen(false);
return;
}
if (e.key !== "Tab") return;
e.preventDefault();
const current = noRef.current;
const next = current !== null && document.activeElement === current ? yesRef.current : noRef.current;
if (next !== null) next.focus({ preventScroll: true });
}
return (
<section
className={`${styles.dz} ${demo ? styles.isDemo : ""}`}
data-open={open ? "yes" : "no"}
style={vars}
>
<p className={styles.head}>
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="16" height="16">{WARNING}</svg>
</span>
{head}
</p>
<p className={styles.desc}>{desc}</p>
<label className={styles.label} htmlFor="dz-input">{label}</label>
<span className={styles.slot}>
<input
className={styles.input}
id="dz-input"
type="text"
autoComplete="off"
spellCheck={false}
placeholder={word}
value={typed}
onChange={handleInput}
/>
<span className={styles.ghost} aria-hidden="true">
<span className={styles.typed}>{word}</span>
</span>
</span>
<button
className={styles.btn}
type="button"
ref={delRef}
aria-disabled={isLocked}
onClick={() => { if (!isLocked) setOpen(true); }}
>
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="14" height="14">{TRASH}</svg>
</span>
{action}
</button>
<p className={styles.live} role="status" aria-live="polite">
{live ? (
live
) : (
<>
<span className={styles.word} data-word="lock">{logs.lock}</span>
<span className={styles.word} data-word="open">{logs.open}</span>
</>
)}
</p>
<span className={styles.scrim} aria-hidden="true" />
<div
className={styles.dialog}
role="alertdialog"
aria-modal="true"
aria-labelledby="dz-dtitle"
aria-describedby="dz-dbody"
onKeyDown={trapFocus}
>
<p className={styles.dtitle} id="dz-dtitle">{dialog.title}</p>
<p className={styles.dbody} id="dz-dbody">{dialog.body}</p>
<span className={styles.drow}>
<button
className={`${styles.dbtn} ${styles.dbtnNo}`}
type="button"
ref={noRef}
onClick={() => setOpen(false)}
>
{dialog.no}
</button>
<button
className={`${styles.dbtn} ${styles.dbtnYes}`}
type="button"
ref={yesRef}
onClick={() => { setOpen(false); setLive(logs.sent); }}
>
{dialog.yes}
</button>
</span>
</div>
</section>
);
}09 Account delete that opens only when you type it out — Measured area 38.426% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · 9 account security settings UI patterns
The post this demo lives in
9 account security settings UI patternsAccount security settings cover everything after the login screen: devices still signed in, two-factor, the password. Al See all demos in this post
If you like this effect - similar demos
Auth UI04 Notice Banner That Slides Out When a Paste Is DetectedShow Password Toggle Icon: 9 Reveal Patterns
Auth UI09 Sign-out confirmation card9 ID Verification Screen UI Patterns, Paste-Ready
Auth UI01 Login card — a card that computes fill ratio to fill its own progress indicator9 Login UI Design Patterns (Copy-Paste)