본인인증 화면 UI 9가지 — 그대로 복사하는 인증 카드
본인인증 화면은 사용자가 본인임을 증명하기 위해 권한 요청부터 인증번호 입력, 로그아웃 확인까지 거치는 일련의 UI 조각을 말합니다. 아래 아홉 개는 서버 쪽 인증 로직이 아니라 이 흐름에서 반복되는 화면 패턴만 애니메이션과 함께 코드로 옮긴 것입니다.
01 auth-permission-modal
02 auth-captcha-checkbox
03 auth-otp-code-grid
04 auth-security-question-card
05 auth-recovery-method-picker
06 auth-onboarding-wizard-step
07 auth-linked-accounts-list
08 auth-session-timeout-banner
09 auth-signout-confirm-card
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 권한 요청 모달
- 02 캡차 확인 체크박스
- 03 인증번호 입력 그리드
- 04 보안 질문 확인 카드
- 05 계정 복구 방법 선택 카드
- 06 온보딩 위저드 스텝 카드
- 07 연결된 계정 목록
- 08 세션 만료 임박 배너
- 09 로그아웃 확인 카드
아홉 개는 인증을 처음 요청받는 순간부터 로그아웃하는 순간까지, 사용자가 실제로 거치는 순서대로 놓았습니다. 권한을 요청하고 사람인지 확인한 뒤 인증번호 입력 화면을 지나고, 받지 못하면 보안 질문이나 복구 수단으로 넘어갑니다. 인증이 끝나면 온보딩과 계정 연결로 이어지고, 세션이 끊기기 전 경고와 로그아웃 확인으로 마무리됩니다. 이 흐름 앞쪽의 첫 관문인 로그인·가입 카드는 로그인 화면 디자인 9가지에, 인증이 끝난 뒤의 설정 화면은 계정 보안 설정 UI 9종에 있습니다.
01권한 요청 모달
휴대폰 본인인증 화면에서 무엇이 보이나
카메라·위치 같은 접근 권한을 물을 때 아이콘이 통통 튀며 나타나고, 허용·거부 버튼 중 허용 쪽만 파란 배경으로 강조됩니다.

<main class="stage">
<div class="perm-scene is-demo">
<div class="perm-scene__page">
<span class="perm-scene__line"></span>
<span class="perm-scene__line perm-scene__line--short"></span>
</div>
<div class="perm-scene__backdrop"></div>
<div class="perm-scene__modal" role="alertdialog" aria-modal="true" aria-label="Camera access">
<span class="perm-scene__icon" aria-hidden="true">
<svg viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 56v56c0 96-88 120-88 120s-88-24-88-120V56a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2"/><path d="M208 40H48a16 16 0 0 0-16 16v56c0 52.72 25.52 84.67 46.93 102.19c23.06 18.86 46 25.26 47 25.53a8 8 0 0 0 4.2 0c1-.27 23.91-6.67 47-25.53C198.48 196.67 224 164.72 224 112V56a16 16 0 0 0-16-16m0 72c0 37.07-13.66 67.16-40.6 89.42a129.3 129.3 0 0 1-39.4 22.2a128.3 128.3 0 0 1-38.92-22.2C61.66 175.16 48 145.07 48 108V56h160Z"/></g></svg>
</span>
<h2 class="perm-scene__title">Allow camera access?</h2>
<p class="perm-scene__desc">Used only during identity verification.</p>
<div class="perm-scene__actions">
<button class="perm-scene__btn perm-scene__btn--ghost" type="button">Deny</button>
<button class="perm-scene__btn perm-scene__btn--primary" type="button">Allow</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter; // 모달 등장 시간
$easing: $ease-pop;
$color: $subject-blue; // 허용 버튼·아이콘 색 (신뢰감 있는 밝은 축)
@include stage($stage-paper);
.perm-scene {
position: relative;
width: min(220px, 80vw);
height: 172px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.perm-scene__page {
position: absolute;
inset: 0;
padding: $sp-4;
background: $stage-paper;
display: flex;
flex-direction: column;
gap: $sp-3;
}
.perm-scene__line { display: block; height: 8px; border-radius: $r-xs; background: #e6e2d8; }
.perm-scene__line--short { width: 55%; }
.perm-scene__backdrop {
position: absolute;
inset: 0;
background: rgba(23, 20, 26, 0);
transition: background $duration $easing;
pointer-events: none;
}
.perm-scene__modal {
position: absolute;
left: 50%; top: 50%;
width: min(184px, 82%);
padding: $sp-3 $sp-4;
background: $stage-paper;
border-radius: $r-card;
box-shadow: $shadow-raised;
text-align: center;
transform: translate(-50%, -50%) scale(.7);
opacity: 0;
transition: transform $duration $easing, opacity $duration $easing;
}
.perm-scene__icon {
display: inline-flex;
width: 30px; height: 30px;
color: $color;
margin-bottom: $sp-1;
animation: icon-bounce $dur-loop $ease-out infinite;
}
.perm-scene__title { margin: 0 0 $sp-1; font-size: 13px; font-weight: 700; color: $stage-ink; font-family: inherit; }
.perm-scene__desc { margin: 0 0 $sp-3; font-size: 11px; color: #6b6455; font-family: inherit; }
.perm-scene__actions { display: flex; gap: $sp-2; justify-content: center; }
.perm-scene__btn {
padding: $sp-2 $sp-4;
border-radius: $r-pill;
font-size: 12px;
font-weight: 700;
font-family: inherit;
cursor: pointer;
border: 1.5px solid transparent;
}
.perm-scene__btn--ghost { background: $stage-paper; border-color: #d8d2c4; color: #6b6455; }
.perm-scene__btn--ghost:hover, .perm-scene__btn--ghost:focus-visible { background: #f3efe4; }
.perm-scene__btn--primary { background: $color; color: $stage-paper; }
.perm-scene__btn--primary:hover, .perm-scene__btn--primary:focus-visible { background: darken($color, 8%); }
// 자동 재생 루프. 사람이 Allow/Deny 를 누르면 is-demo 를 떼고 실제로 닫힌다 (index.html)
.perm-scene.is-demo .perm-scene__backdrop { animation: backdrop-cycle $dur-loop $easing infinite; }
.perm-scene.is-demo .perm-scene__modal { animation: modal-cycle $dur-loop $easing infinite; }
.perm-scene.is-closed .perm-scene__backdrop { background: rgba(23, 20, 26, 0); }
.perm-scene.is-closed .perm-scene__modal { transform: translate(-50%, -50%) scale(.7); opacity: 0; }
@keyframes backdrop-cycle {
0%, 8% { background: rgba(23, 20, 26, 0); }
23%, 77% { background: rgba(23, 20, 26, .35); }
92%, 100% { background: rgba(23, 20, 26, 0); }
}
@keyframes modal-cycle {
0%, 8% { transform: translate(-50%, -50%) scale(.7); opacity: 0; }
23%, 77% { transform: translate(-50%, -50%) scale(1); opacity: 1; }
92%, 100% { transform: translate(-50%, -50%) scale(.7); opacity: 0; }
}
@keyframes icon-bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
@media (prefers-reduced-motion: reduce) {
.perm-scene__backdrop, .perm-scene__modal { transition: none; }
.perm-scene.is-demo .perm-scene__backdrop,
.perm-scene.is-demo .perm-scene__modal,
.perm-scene__icon { animation: none; }
}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);
}
.perm-scene {
position: relative;
width: min(220px, 80vw);
height: 172px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.perm-scene__page {
position: absolute;
inset: 0;
padding: 16px;
background: #ffffff;
display: flex;
flex-direction: column;
gap: 12px;
}
.perm-scene__line {
display: block;
height: 8px;
border-radius: 4px;
background: #e6e2d8;
}
.perm-scene__line--short {
width: 55%;
}
.perm-scene__backdrop {
position: absolute;
inset: 0;
background: rgba(23, 20, 26, 0);
transition: background 600ms cubic-bezier(0.2, 1.4, 0.4, 1);
pointer-events: none;
}
.perm-scene__modal {
position: absolute;
left: 50%;
top: 50%;
width: min(184px, 82%);
padding: 12px 16px;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
text-align: center;
transform: translate(-50%, -50%) scale(0.7);
opacity: 0;
transition: transform 600ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 600ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.perm-scene__icon {
display: inline-flex;
width: 30px;
height: 30px;
color: #2f6df6;
margin-bottom: 4px;
animation: icon-bounce 2s ease-out infinite;
}
.perm-scene__title {
margin: 0 0 4px;
font-size: 13px;
font-weight: 700;
color: #17141a;
font-family: inherit;
}
.perm-scene__desc {
margin: 0 0 12px;
font-size: 11px;
color: #6b6455;
font-family: inherit;
}
.perm-scene__actions {
display: flex;
gap: 8px;
justify-content: center;
}
.perm-scene__btn {
padding: 8px 16px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
font-family: inherit;
cursor: pointer;
border: 1.5px solid transparent;
}
.perm-scene__btn--ghost {
background: #ffffff;
border-color: #d8d2c4;
color: #6b6455;
}
.perm-scene__btn--ghost:hover, .perm-scene__btn--ghost:focus-visible {
background: #f3efe4;
}
.perm-scene__btn--primary {
background: #2f6df6;
color: #ffffff;
}
.perm-scene__btn--primary:hover, .perm-scene__btn--primary:focus-visible {
background: rgb(4.1019246408%, 32.3596277221%, 94.8000361435%);
}
.perm-scene.is-demo .perm-scene__backdrop {
animation: backdrop-cycle 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.perm-scene.is-demo .perm-scene__modal {
animation: modal-cycle 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.perm-scene.is-closed .perm-scene__backdrop {
background: rgba(23, 20, 26, 0);
}
.perm-scene.is-closed .perm-scene__modal {
transform: translate(-50%, -50%) scale(0.7);
opacity: 0;
}
@keyframes backdrop-cycle {
0%, 8% {
background: rgba(23, 20, 26, 0);
}
23%, 77% {
background: rgba(23, 20, 26, 0.35);
}
92%, 100% {
background: rgba(23, 20, 26, 0);
}
}
@keyframes modal-cycle {
0%, 8% {
transform: translate(-50%, -50%) scale(0.7);
opacity: 0;
}
23%, 77% {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
92%, 100% {
transform: translate(-50%, -50%) scale(0.7);
opacity: 0;
}
}
@keyframes icon-bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
@media (prefers-reduced-motion: reduce) {
.perm-scene__backdrop, .perm-scene__modal {
transition: none;
}
.perm-scene.is-demo .perm-scene__backdrop,
.perm-scene.is-demo .perm-scene__modal,
.perm-scene__icon {
animation: none;
}
}(function(){
var scene=document.querySelector('.perm-scene'),
modal=scene.querySelector('.perm-scene__modal'),
deny=scene.querySelector('.perm-scene__btn--ghost'),
allow=scene.querySelector('.perm-scene__btn--primary');
function close(){ scene.classList.remove('is-demo'); scene.classList.add('is-closed'); }
deny.addEventListener('click', close);
allow.addEventListener('click', close);
modal.addEventListener('keydown', function(e){ if(e.key==='Escape') close(); });
}());import { useState } from "react";
import styles from "./PermissionRequestModal.module.scss";
export type PermissionRequestModalProps = {
title?: string;
description?: string;
onAllow?: () => void;
onDeny?: () => void;
};
/** 권한 요청 모달 — Allow/Deny 또는 Escape 로 닫힌다 */
export default function PermissionRequestModal({
title = "Allow camera access?",
description = "Used only during identity verification.",
onAllow,
onDeny,
}: PermissionRequestModalProps) {
const [open, setOpen] = useState(true);
if (!open) return null;
const close = (fn?: () => void) => () => {
fn?.();
setOpen(false);
};
return (
<div className={styles.scene}>
<div className={styles.backdrop} />
<div
className={styles.modal}
role="alertdialog"
aria-modal="true"
aria-label={title}
onKeyDown={(e: any) => e.key === "Escape" && setOpen(false)}
>
<span className={styles.icon} aria-hidden="true">
<svg viewBox="0 0 256 256">
<g fill="currentColor">
<path d="M216 56v56c0 96-88 120-88 120s-88-24-88-120V56a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" opacity=".2" />
<path d="M208 40H48a16 16 0 0 0-16 16v56c0 52.72 25.52 84.67 46.93 102.19c23.06 18.86 46 25.26 47 25.53a8 8 0 0 0 4.2 0c1-.27 23.91-6.67 47-25.53C198.48 196.67 224 164.72 224 112V56a16 16 0 0 0-16-16m0 72c0 37.07-13.66 67.16-40.6 89.42a129.3 129.3 0 0 1-39.4 22.2a128.3 128.3 0 0 1-38.92-22.2C61.66 175.16 48 145.07 48 108V56h160Z" />
</g>
</svg>
</span>
<h2 className={styles.title}>{title}</h2>
<p className={styles.desc}>{description}</p>
<div className={styles.actions}>
<button className={styles.ghost} type="button" onClick={close(onDeny)}>Deny</button>
<button className={styles.primary} type="button" onClick={close(onAllow)}>Allow</button>
</div>
</div>
</div>
);
}01 · 권한 요청 모달 — 측정 누적면적 26.15% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
본인인증 전 카메라·마이크 접근을 요청하는 화면이나 앱 첫 실행 시 알림 권한 안내에 씁니다.
(function(){
var scene = document.querySelector('.perm-scene');
var modal = scene.querySelector('.perm-scene__modal');
var deny = scene.querySelector('.perm-scene__btn--ghost');
var allow = scene.querySelector('.perm-scene__btn--primary');
function close(){ scene.classList.remove('is-demo'); scene.classList.add('is-closed'); }
deny.addEventListener('click', close);
allow.addEventListener('click', close);
modal.addEventListener('keydown', function(e){
if (e.key === 'Escape') close();
});
}());
02캡차 확인 체크박스
화면에서 일어나는 일
체크박스를 누르면 로딩 스피너가 잠깐 돌다가 초록 체크가 스프링처럼 튀어 오르고, 뒤이어 '확인됨' 배지가 떠오릅니다.

<main class="stage">
<div class="captcha-card is-demo">
<label class="captcha">
<input class="captcha__input" type="checkbox">
<span class="captcha__box">
<span class="captcha__spinner" aria-hidden="true"></span>
<svg class="captcha__check" viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor">
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/>
<path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 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="captcha__label">I'm not a robot</span>
</label>
<span class="captcha__badge">Verified</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 스피너 → 체크 전환 주기
$easing: $ease-pop;
$color: $subject-mint; // 확인 색 (상큼한 밝은 축)
@include stage($stage-paper);
.captcha-card {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.captcha {
display: flex;
align-items: center;
gap: $sp-3;
padding: $sp-3 $sp-5;
background: $stage-paper;
border: 1.5px solid #d8d2c4;
border-radius: $r-card;
box-shadow: $shadow-press;
cursor: pointer;
font-family: inherit;
}
.captcha__input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.captcha__box {
position: relative;
width: 26px; height: 26px;
flex: 0 0 auto;
border-radius: $r-xs;
border: 2px solid #c9c2b2;
background: $stage-paper;
}
.captcha__spinner {
position: absolute;
inset: 3px;
border-radius: 50%;
border: 3px solid rgba($color, .2);
border-top-color: $color;
opacity: 0;
}
.captcha__check {
position: absolute;
inset: 0;
color: $color;
opacity: 0;
transform: scale(.4);
}
.captcha__label { font-size: 13px; font-weight: 600; color: $stage-ink; }
.captcha__badge {
font-size: 11px;
font-weight: 700;
color: $color;
opacity: 0;
transform: translateY(4px);
}
// 실제 사용 — 클릭해서 체크하면 즉시 확인 표시 (is-demo 없이 열었을 때)
.captcha__input:checked ~ .captcha__box .captcha__check { opacity: 1; transform: scale(1); transition: opacity $dur-quick $ease-out, transform $dur-quick $ease-pop; }
.captcha__input:checked ~ .captcha__box { border-color: $color; }
.captcha__input:focus-visible ~ .captcha__box { outline: 2px solid $color; outline-offset: 2px; }
// 자동 재생 루프 — 로딩 → 확인 → 배지 → 리셋
.captcha-card.is-demo .captcha { animation: capt-card $duration $easing infinite; }
.captcha-card.is-demo .captcha__spinner { animation: capt-spinner $duration linear infinite; }
.captcha-card.is-demo .captcha__check { animation: capt-check $duration $easing infinite; }
.captcha-card.is-demo .captcha__badge { animation: capt-badge $duration $ease-out infinite; }
@keyframes capt-card {
0%, 28% { background: $stage-paper; border-color: #d8d2c4; box-shadow: $shadow-press; }
40%, 84% { background: mix($color, white, 12%); border-color: $color; box-shadow: 0 0 0 4px rgba($color, .18), 0 0 26px 14px rgba($color, .22), $shadow-press; }
92%, 100% { background: $stage-paper; border-color: #d8d2c4; box-shadow: $shadow-press; }
}
@keyframes capt-spinner {
0% { opacity: 0; transform: rotate(0deg); }
8% { opacity: 1; }
28% { opacity: 1; transform: rotate(560deg); }
32%, 100% { opacity: 0; transform: rotate(560deg); }
}
@keyframes capt-check {
0%, 28% { opacity: 0; transform: scale(.4); }
40% { opacity: 1; transform: scale(1.15); }
46%, 84% { opacity: 1; transform: scale(1); }
92%, 100% { opacity: 0; transform: scale(.4); }
}
@keyframes capt-badge {
0%, 40% { opacity: 0; transform: translateY(4px); }
48%, 84% { opacity: 1; transform: translateY(0); }
92%, 100% { opacity: 0; transform: translateY(4px); }
}
@media (prefers-reduced-motion: reduce) {
.captcha-card.is-demo .captcha__spinner,
.captcha-card.is-demo .captcha__check,
.captcha-card.is-demo .captcha__badge { animation: none; }
.captcha__input:checked ~ .captcha__box .captcha__check { transition: none; }
}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);
}
.captcha-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.captcha {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
background: #ffffff;
border: 1.5px solid #d8d2c4;
border-radius: 16px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: pointer;
font-family: inherit;
}
.captcha__input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.captcha__box {
position: relative;
width: 26px;
height: 26px;
flex: 0 0 auto;
border-radius: 4px;
border: 2px solid #c9c2b2;
background: #ffffff;
}
.captcha__spinner {
position: absolute;
inset: 3px;
border-radius: 50%;
border: 3px solid rgba(76, 212, 166, 0.2);
border-top-color: #4cd4a6;
opacity: 0;
}
.captcha__check {
position: absolute;
inset: 0;
color: #4cd4a6;
opacity: 0;
transform: scale(0.4);
}
.captcha__label {
font-size: 13px;
font-weight: 600;
color: #17141a;
}
.captcha__badge {
font-size: 11px;
font-weight: 700;
color: #4cd4a6;
opacity: 0;
transform: translateY(4px);
}
.captcha__input:checked ~ .captcha__box .captcha__check {
opacity: 1;
transform: scale(1);
transition: opacity 220ms ease-out, transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.captcha__input:checked ~ .captcha__box {
border-color: #4cd4a6;
}
.captcha__input:focus-visible ~ .captcha__box {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.captcha-card.is-demo .captcha {
animation: capt-card 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.captcha-card.is-demo .captcha__spinner {
animation: capt-spinner 2s linear infinite;
}
.captcha-card.is-demo .captcha__check {
animation: capt-check 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.captcha-card.is-demo .captcha__badge {
animation: capt-badge 2s ease-out infinite;
}
@keyframes capt-card {
0%, 28% {
background: #ffffff;
border-color: #d8d2c4;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
40%, 84% {
background: rgb(91.5764705882%, 97.9764705882%, 95.8117647059%);
border-color: #4cd4a6;
box-shadow: 0 0 0 4px rgba(76, 212, 166, 0.18), 0 0 26px 14px rgba(76, 212, 166, 0.22), 0 6px 12px rgba(23, 20, 26, 0.32);
}
92%, 100% {
background: #ffffff;
border-color: #d8d2c4;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
}
@keyframes capt-spinner {
0% {
opacity: 0;
transform: rotate(0deg);
}
8% {
opacity: 1;
}
28% {
opacity: 1;
transform: rotate(560deg);
}
32%, 100% {
opacity: 0;
transform: rotate(560deg);
}
}
@keyframes capt-check {
0%, 28% {
opacity: 0;
transform: scale(0.4);
}
40% {
opacity: 1;
transform: scale(1.15);
}
46%, 84% {
opacity: 1;
transform: scale(1);
}
92%, 100% {
opacity: 0;
transform: scale(0.4);
}
}
@keyframes capt-badge {
0%, 40% {
opacity: 0;
transform: translateY(4px);
}
48%, 84% {
opacity: 1;
transform: translateY(0);
}
92%, 100% {
opacity: 0;
transform: translateY(4px);
}
}
@media (prefers-reduced-motion: reduce) {
.captcha-card.is-demo .captcha__spinner,
.captcha-card.is-demo .captcha__check,
.captcha-card.is-demo .captcha__badge {
animation: none;
}
.captcha__input:checked ~ .captcha__box .captcha__check {
transition: none;
}
}import { useState } from "react";
import styles from "./CaptchaVerifyCheckbox.module.scss";
export type CaptchaVerifyCheckboxProps = {
label?: string;
onVerify?: (checked: boolean) => void;
};
/** 캡차 확인 체크박스 — 체크하면 즉시 확인 배지가 뜬다 */
export default function CaptchaVerifyCheckbox({
label = "I'm not a robot",
onVerify,
}: CaptchaVerifyCheckboxProps) {
const [checked, setChecked] = useState(false);
const toggle = () => {
const next = !checked;
setChecked(next);
onVerify?.(next);
};
return (
<div className={styles.card}>
<label className={styles.captcha}>
<input
className={styles.input}
type="checkbox"
checked={checked}
onChange={toggle}
/>
<span className={checked ? `${styles.box} ${styles.boxChecked}` : styles.box}>
<svg className={styles.check} viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor">
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 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 className={styles.label}>{label}</span>
</label>
{checked && <span className={styles.badge}>Verified</span>}
</div>
);
}02 · 캡차 확인 체크박스 — 측정 누적면적 13.285% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
인증번호 발송 전 사람인지 확인하는 단계나 무료 체험 신청 폼의 스팸 방지에 씁니다.
.captcha__input:checked ~ .captcha__box .captcha__check {
opacity: 1;
transform: scale(1);
transition: opacity $dur-quick $ease-out, transform $dur-quick $ease-pop;
}
.captcha__input:checked ~ .captcha__box { border-color: $color; }
.captcha__input:focus-visible ~ .captcha__box { outline: 2px solid $color; outline-offset: 2px; }
@keyframes capt-check {
0%, 28% { opacity: 0; transform: scale(.4); }
40% { opacity: 1; transform: scale(1.15); }
46%, 84% { opacity: 1; transform: scale(1); }
92%, 100% { opacity: 0; transform: scale(.4); }
}
03인증번호 입력 그리드
어떤 움직임인가
숫자 칸 6개가 나란히 있고, 한 칸에 입력하면 자동으로 다음 칸으로 넘어가며 칸마다 테두리가 파랗게 반짝입니다.

<main class="stage">
<div class="otp-card">
<span class="otp-card__icon" aria-hidden="true">
<svg viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 88H48a8 8 0 0 0-8 8v112a8 8 0 0 0 8 8h160a8 8 0 0 0 8-8V96a8 8 0 0 0-8-8m-80 72a20 20 0 1 1 20-20a20 20 0 0 1-20 20" opacity=".2"/><path d="M208 80h-32V56a48 48 0 0 0-96 0v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16M96 56a32 32 0 0 1 64 0v24H96Zm112 152H48V96h160zm-80-96a28 28 0 0 0-8 54.83V184a8 8 0 0 0 16 0v-17.17a28 28 0 0 0-8-54.83m0 40a12 12 0 1 1 12-12a12 12 0 0 1-12 12"/></g></svg>
</span>
<p class="otp-card__desc">Enter the 6-digit code we texted you</p>
<div class="otp is-demo" role="group" aria-label="One-time code">
<input class="otp__box" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="1" aria-label="Digit 1">
<input class="otp__box" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="1" aria-label="Digit 2">
<input class="otp__box" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="1" aria-label="Digit 3">
<input class="otp__box" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="1" aria-label="Digit 4">
<input class="otp__box" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="1" aria-label="Digit 5">
<input class="otp__box" type="text" inputmode="numeric" pattern="[0-9]*" maxlength="1" aria-label="Digit 6">
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 칸마다 번지는 포커스 웨이브 한 바퀴
$easing: $ease-out;
$color: $subject-blue; // 포커스 강조 색 (신뢰감 있는 밝은 축)
@include stage($stage-paper);
.otp-card {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
width: min(280px, 88vw);
}
.otp-card__icon { width: 22px; height: 22px; color: $color; }
.otp-card__desc { margin: 0 0 $sp-1; font-size: 12px; color: #6b6455; font-family: inherit; text-align: center; }
.otp {
display: flex;
gap: $sp-2;
}
.otp__box {
width: 34px; height: 42px;
border: 2px solid #d8d2c4;
border-radius: $r-sm;
background: $stage-paper;
text-align: center;
font-size: 18px;
font-weight: 700;
font-family: inherit;
color: $stage-ink;
box-shadow: none;
}
.otp__box:focus-visible {
outline: none;
border-color: $color;
box-shadow: 0 0 0 3px rgba($color, .25);
}
// 자동 재생 — 칸마다 순서대로 포커스 링이 번진다 (실제 입력은 JS 가 처리, index.html)
.otp.is-demo .otp__box { animation: otp-wave $duration $easing infinite; }
.otp.is-demo .otp__box:nth-child(1) { animation-delay: 0ms; }
.otp.is-demo .otp__box:nth-child(2) { animation-delay: 120ms; }
.otp.is-demo .otp__box:nth-child(3) { animation-delay: 240ms; }
.otp.is-demo .otp__box:nth-child(4) { animation-delay: 360ms; }
.otp.is-demo .otp__box:nth-child(5) { animation-delay: 480ms; }
.otp.is-demo .otp__box:nth-child(6) { animation-delay: 600ms; }
@keyframes otp-wave {
0%, 84%, 100% { border-color: #d8d2c4; box-shadow: none; transform: scale(1); }
6% { border-color: $color; box-shadow: 0 0 0 4px rgba($color, .3), 0 0 22px 12px rgba($color, .35); transform: scale(1.1); }
18% { border-color: #d8d2c4; box-shadow: none; transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.otp.is-demo .otp__box { animation: none; }
}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);
}
.otp-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: min(280px, 88vw);
}
.otp-card__icon {
width: 22px;
height: 22px;
color: #2f6df6;
}
.otp-card__desc {
margin: 0 0 4px;
font-size: 12px;
color: #6b6455;
font-family: inherit;
text-align: center;
}
.otp {
display: flex;
gap: 8px;
}
.otp__box {
width: 34px;
height: 42px;
border: 2px solid #d8d2c4;
border-radius: 8px;
background: #ffffff;
text-align: center;
font-size: 18px;
font-weight: 700;
font-family: inherit;
color: #17141a;
box-shadow: none;
}
.otp__box:focus-visible {
outline: none;
border-color: #2f6df6;
box-shadow: 0 0 0 3px rgba(47, 109, 246, 0.25);
}
.otp.is-demo .otp__box {
animation: otp-wave 2s ease-out infinite;
}
.otp.is-demo .otp__box:nth-child(1) {
animation-delay: 0ms;
}
.otp.is-demo .otp__box:nth-child(2) {
animation-delay: 120ms;
}
.otp.is-demo .otp__box:nth-child(3) {
animation-delay: 240ms;
}
.otp.is-demo .otp__box:nth-child(4) {
animation-delay: 360ms;
}
.otp.is-demo .otp__box:nth-child(5) {
animation-delay: 480ms;
}
.otp.is-demo .otp__box:nth-child(6) {
animation-delay: 600ms;
}
@keyframes otp-wave {
0%, 84%, 100% {
border-color: #d8d2c4;
box-shadow: none;
transform: scale(1);
}
6% {
border-color: #2f6df6;
box-shadow: 0 0 0 4px rgba(47, 109, 246, 0.3), 0 0 22px 12px rgba(47, 109, 246, 0.35);
transform: scale(1.1);
}
18% {
border-color: #d8d2c4;
box-shadow: none;
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.otp.is-demo .otp__box {
animation: none;
}
}(function(){
var boxes = Array.prototype.slice.call(document.querySelectorAll('.otp__box'));
boxes.forEach(function(box, i){
box.addEventListener('input', function(){
box.value = box.value.replace(/[^0-9]/g, '').slice(0, 1);
if (box.value && boxes[i + 1]) boxes[i + 1].focus({ preventScroll: true });
});
box.addEventListener('keydown', function(e){
if (e.key === 'Backspace' && !box.value && boxes[i - 1]) boxes[i - 1].focus({ preventScroll: true });
});
box.addEventListener('paste', function(e){
e.preventDefault();
var digits = (e.clipboardData.getData('text') || '').replace(/[^0-9]/g, '').split('');
boxes.forEach(function(b, j){ b.value = digits[j] || ''; });
(boxes[Math.min(digits.length, boxes.length) - 1] || boxes[0]).focus({ preventScroll: true });
});
});
}());import { useRef, useState } from "react";
import styles from "./OtpCodeGrid.module.scss";
export type OtpCodeGridProps = {
length?: number;
onComplete?: (code: string) => void;
};
/** 인증번호 입력 그리드 — 자동 이동 + 붙여넣기 분배 */
export default function OtpCodeGrid({ length = 6, onComplete }: OtpCodeGridProps) {
const [digits, setDigits] = useState<string[]>(Array(length).fill(""));
const refs = useRef<Array<HTMLInputElement | null>>([]);
const setDigit = (i: number, value: string) => {
const next = [...digits];
next[i] = value;
setDigits(next);
if (next.every((d) => d)) onComplete?.(next.join(""));
};
return (
<div className={styles.card}>
<span className={styles.icon} aria-hidden="true">
<svg viewBox="0 0 256 256">
<g fill="currentColor">
<path d="M208 88H48a8 8 0 0 0-8 8v112a8 8 0 0 0 8 8h160a8 8 0 0 0 8-8V96a8 8 0 0 0-8-8m-80 72a20 20 0 1 1 20-20a20 20 0 0 1-20 20" opacity=".2" />
<path d="M208 80h-32V56a48 48 0 0 0-96 0v24H48a16 16 0 0 0-16 16v112a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16M96 56a32 32 0 0 1 64 0v24H96Zm112 152H48V96h160zm-80-96a28 28 0 0 0-8 54.83V184a8 8 0 0 0 16 0v-17.17a28 28 0 0 0-8-54.83m0 40a12 12 0 1 1 12-12a12 12 0 0 1-12 12" />
</g>
</svg>
</span>
<p className={styles.desc}>Enter the {length}-digit code we texted you</p>
<div className={styles.otp} role="group" aria-label="One-time code">
{digits.map((d, i) => (
<input
key={i}
ref={(el: HTMLInputElement | null) => { refs.current![i] = el; }}
className={styles.box}
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={1}
value={d}
aria-label={`Digit ${i + 1}`}
onChange={(e: any) => {
const v = e.target.value.replace(/[^0-9]/g, "").slice(0, 1);
setDigit(i, v);
if (v && refs.current![i + 1]) refs.current![i + 1]!.focus({ preventScroll: true });
}}
onKeyDown={(e: any) => {
if (e.key === "Backspace" && !digits[i] && refs.current![i - 1]) refs.current![i - 1]!.focus({ preventScroll: true });
}}
onPaste={(e: any) => {
e.preventDefault();
const pasted = e.clipboardData.getData("text").replace(/[^0-9]/g, "").split("");
const next = Array(length).fill("").map((_, j) => pasted[j] || "");
setDigits(next);
if (next.every((n) => n)) onComplete?.(next.join(""));
refs.current![Math.min(pasted.length, length) - 1]?.focus({ preventScroll: true });
}}
/>
))}
</div>
</div>
);
}03 · 인증번호 입력 그리드 — 측정 누적면적 15.469% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
문자로 받은 인증번호 6자리 입력창이나 2단계 인증 코드 입력 화면에 씁니다.
boxes.forEach(function(box, i){
box.addEventListener('input', function(){
box.value = box.value.replace(/[^0-9]/g, '').slice(0, 1);
if (box.value && boxes[i + 1]) boxes[i + 1].focus({ preventScroll: true });
});
box.addEventListener('keydown', function(e){
if (e.key === 'Backspace' && !box.value && boxes[i - 1]) boxes[i - 1].focus({ preventScroll: true });
});
box.addEventListener('paste', function(e){
e.preventDefault();
var digits = (e.clipboardData.getData('text') || '').replace(/[^0-9]/g, '').split('');
boxes.forEach(function(b, j){ b.value = digits[j] || ''; });
(boxes[Math.min(digits.length, boxes.length) - 1] || boxes[0]).focus({ preventScroll: true });
});
});
04보안 질문 확인 카드
무엇이 보이나
미리 등록한 질문 하나가 카드에 뜨고, 답을 입력하면 입력칸 테두리가 빨강에서 베이지로 서서히 식듯이 바뀝니다.

<main class="stage">
<div class="sq-card is-demo">
<div class="sq-card__panel">
<span class="sq-card__icon" aria-hidden="true">
<svg 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="M140 180a12 12 0 1 1-12-12a12 12 0 0 1 12 12M128 72c-22.06 0-40 16.15-40 36v4a8 8 0 0 0 16 0v-4c0-11 10.77-20 24-20s24 9 24 20s-10.77 20-24 20a8 8 0 0 0-8 8v8a8 8 0 0 0 16 0v-.72c18.24-3.35 32-17.9 32-35.28c0-19.85-17.94-36-40-36m104 56A104 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>
<p class="sq-card__question">What was your first pet's name?</p>
<input class="sq-card__input" type="text" placeholder="Your answer" aria-label="Security question answer">
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 답변 카드가 식었다 다시 데워지는 한 주기
$easing: $ease-out;
$color: $error; // 뜨거운 경고 축 (식기 전 색)
@include stage($stage-orange);
.sq-card { display: flex; align-items: center; justify-content: center; }
.sq-card__panel {
width: min(240px, 84vw);
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
padding: $sp-5 $sp-4;
background: $stage-paper;
border-radius: $r-card;
box-shadow: $shadow-raised;
text-align: center;
}
.sq-card__icon { width: 28px; height: 28px; color: $color; }
.sq-card__question { margin: 0; font-size: 13px; font-weight: 600; color: $stage-ink; font-family: inherit; }
.sq-card__input {
width: 100%;
box-sizing: border-box;
padding: $sp-2 $sp-3;
border: 2px solid #d8d2c4;
border-radius: $r-sm;
background: $stage-paper;
font-size: 13px;
font-family: inherit;
color: $stage-ink;
outline: none;
}
// 실제 사용 — 입력창에 포커스하면 즉시 반응 (is-demo 없이 열었을 때)
.sq-card__input:focus-visible { border-color: $subject-blue; box-shadow: 0 0 0 3px rgba($subject-blue, .25); }
// 자동 재생 루프 — 미리 등록한 답을 다시 확인하는 동안 카드가 뜨거운 색에서 식는다
.sq-card.is-demo .sq-card__panel { animation: sq-panel $duration $easing infinite; }
.sq-card.is-demo .sq-card__input { animation: sq-input $duration $easing infinite; }
@keyframes sq-panel {
0%, 8% { background: mix($color, white, 24%); box-shadow: 0 0 0 3px rgba($color, .3), 0 0 34px 18px rgba($color, .3), $shadow-raised; }
23%, 77% { background: $stage-paper; box-shadow: $shadow-raised; }
92%, 100% { background: mix($color, white, 24%); box-shadow: 0 0 0 3px rgba($color, .3), 0 0 34px 18px rgba($color, .3), $shadow-raised; }
}
@keyframes sq-input {
0%, 8% { border-color: $color; box-shadow: 0 0 0 4px rgba($color, .22), 0 0 20px 10px rgba($color, .25); }
23%, 77% { border-color: #d8d2c4; box-shadow: none; }
92%, 100% { border-color: $color; box-shadow: 0 0 0 4px rgba($color, .22), 0 0 20px 10px rgba($color, .25); }
}
@media (prefers-reduced-motion: reduce) {
.sq-card.is-demo .sq-card__panel,
.sq-card.is-demo .sq-card__input { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sq-card {
display: flex;
align-items: center;
justify-content: center;
}
.sq-card__panel {
width: min(240px, 84vw);
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px 16px;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
text-align: center;
}
.sq-card__icon {
width: 28px;
height: 28px;
color: #c2361a;
}
.sq-card__question {
margin: 0;
font-size: 13px;
font-weight: 600;
color: #17141a;
font-family: inherit;
}
.sq-card__input {
width: 100%;
box-sizing: border-box;
padding: 8px 12px;
border: 2px solid #d8d2c4;
border-radius: 8px;
background: #ffffff;
font-size: 13px;
font-family: inherit;
color: #17141a;
outline: none;
}
.sq-card__input:focus-visible {
border-color: #2f6df6;
box-shadow: 0 0 0 3px rgba(47, 109, 246, 0.25);
}
.sq-card.is-demo .sq-card__panel {
animation: sq-panel 2s ease-out infinite;
}
.sq-card.is-demo .sq-card__input {
animation: sq-input 2s ease-out infinite;
}
@keyframes sq-panel {
0%, 8% {
background: rgb(94.2588235294%, 81.0823529412%, 78.4470588235%);
box-shadow: 0 0 0 3px rgba(194, 54, 26, 0.3), 0 0 34px 18px rgba(194, 54, 26, 0.3), 0 14px 24px rgba(23, 20, 26, 0.32);
}
23%, 77% {
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
92%, 100% {
background: rgb(94.2588235294%, 81.0823529412%, 78.4470588235%);
box-shadow: 0 0 0 3px rgba(194, 54, 26, 0.3), 0 0 34px 18px rgba(194, 54, 26, 0.3), 0 14px 24px rgba(23, 20, 26, 0.32);
}
}
@keyframes sq-input {
0%, 8% {
border-color: #c2361a;
box-shadow: 0 0 0 4px rgba(194, 54, 26, 0.22), 0 0 20px 10px rgba(194, 54, 26, 0.25);
}
23%, 77% {
border-color: #d8d2c4;
box-shadow: none;
}
92%, 100% {
border-color: #c2361a;
box-shadow: 0 0 0 4px rgba(194, 54, 26, 0.22), 0 0 20px 10px rgba(194, 54, 26, 0.25);
}
}
@media (prefers-reduced-motion: reduce) {
.sq-card.is-demo .sq-card__panel,
.sq-card.is-demo .sq-card__input {
animation: none;
}
}import { useState } from "react";
import styles from "./SecurityQuestionCard.module.scss";
export type SecurityQuestionCardProps = {
question: string;
onAnswer?: (value: string) => void;
};
/** OTP 를 못 받을 때 쓰는 대체 인증 — 등록해둔 질문에 답을 입력한다 */
export default function SecurityQuestionCard({ question, onAnswer }: SecurityQuestionCardProps) {
const [value, setValue] = useState("");
return (
<div className={styles.panel}>
<span className={styles.icon} aria-hidden="true">
<svg 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="M140 180a12 12 0 1 1-12-12a12 12 0 0 1 12 12M128 72c-22.06 0-40 16.15-40 36v4a8 8 0 0 0 16 0v-4c0-11 10.77-20 24-20s24 9 24 20s-10.77 20-24 20a8 8 0 0 0-8 8v8a8 8 0 0 0 16 0v-.72c18.24-3.35 32-17.9 32-35.28c0-19.85-17.94-36-40-36m104 56A104 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>
<p className={styles.question}>{question}</p>
<input
className={styles.input}
type="text"
placeholder="Your answer"
aria-label="Security question answer"
value={value}
onChange={(e: any) => {
setValue(e.target.value);
onAnswer?.(e.target.value);
}}
/>
</div>
);
}04 · 보안 질문 확인 카드 — 측정 누적면적 26.226% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
OTP 를 못 받을 때의 대체 인증 단계나 관리자 계정의 2차 확인 화면에 씁니다.
.sq-card__input:focus-visible { border-color: $subject-blue; box-shadow: 0 0 0 3px rgba($subject-blue, .25); }
@keyframes sq-panel {
0%, 8% { background: mix($color, white, 24%); box-shadow: 0 0 0 3px rgba($color, .3), 0 0 34px 18px rgba($color, .3), $shadow-raised; }
23%, 77% { background: $stage-paper; box-shadow: $shadow-raised; }
92%, 100% { background: mix($color, white, 24%); box-shadow: 0 0 0 3px rgba($color, .3), 0 0 34px 18px rgba($color, .3), $shadow-raised; }
}
@keyframes sq-input {
0%, 8% { border-color: $color; box-shadow: 0 0 0 4px rgba($color, .22), 0 0 20px 10px rgba($color, .25); }
23%, 77% { border-color: #d8d2c4; box-shadow: none; }
92%, 100% { border-color: $color; box-shadow: 0 0 0 4px rgba($color, .22), 0 0 20px 10px rgba($color, .25); }
}
05계정 복구 방법 선택 카드
화면에서 일어나는 일
이메일·문자·보안 질문 세 옵션이 라디오 카드로 나열되고, 고르면 선택된 카드만 배경이 어두운 화면 위에서 밝게 떠오릅니다.

<main class="stage">
<div class="recovery-scene">
<span class="recovery-scene__icon" aria-hidden="true">
<svg viewBox="0 0 256 256"><g fill="currentColor"><path d="M232 98.36c-1.27 38.56-33.33 69.64-71.91 69.64a71.7 71.7 0 0 1-26.92-5.17L120 176H96v24H72v24H40a8 8 0 0 1-8-8v-28.69a8 8 0 0 1 2.34-5.65l58.83-58.83A71.7 71.7 0 0 1 88 95.91c0-38.58 31.08-70.64 69.64-71.87A72 72 0 0 1 232 98.36" opacity=".2"/><path d="M216.57 39.43a80 80 0 0 0-132.66 81.35L28.69 176A15.86 15.86 0 0 0 24 187.31V216a16 16 0 0 0 16 16h32a8 8 0 0 0 8-8v-16h16a8 8 0 0 0 8-8v-16h16a8 8 0 0 0 5.66-2.34l9.56-9.57A79.7 79.7 0 0 0 160 176h.1a80 80 0 0 0 56.47-136.57M224 98.1c-1.09 34.09-29.75 61.86-63.89 61.9H160a63.7 63.7 0 0 1-23.65-4.51a8 8 0 0 0-8.84 1.68L116.69 168H96a8 8 0 0 0-8 8v16H72a8 8 0 0 0-8 8v16H40v-28.69l58.83-58.82a8 8 0 0 0 1.68-8.84A63.7 63.7 0 0 1 96 95.92c0-34.14 27.81-62.8 61.9-63.89A64 64 0 0 1 224 98.1M192 76a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/></g></svg>
</span>
<p class="recovery-scene__title">Choose a recovery method</p>
<div class="recovery is-demo" role="radiogroup" aria-label="Choose recovery method">
<label class="recovery__card">
<input class="recovery__input" type="radio" name="recovery" value="email" checked>
<span class="recovery__label">Email</span>
</label>
<label class="recovery__card">
<input class="recovery__input" type="radio" name="recovery" value="text">
<span class="recovery__label">Text message</span>
</label>
<label class="recovery__card">
<input class="recovery__input" type="radio" name="recovery" value="question">
<span class="recovery__label">Security question</span>
</label>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 세 카드를 순서대로 들어올리는 한 바퀴
$easing: $ease-out;
$color: $subject-blue; // 신뢰감 있는 밝은 축
@include stage($stage-ink);
.recovery-scene { display: flex; flex-direction: column; align-items: center; gap: $sp-3; }
.recovery-scene__icon { width: 24px; height: 24px; color: $color; }
.recovery-scene__title { margin: 0; font-size: 13px; font-weight: 700; color: $stage-paper; font-family: inherit; }
.recovery {
display: flex;
gap: $sp-3;
width: min(300px, 90vw);
}
.recovery__card {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-1;
padding: $sp-3 $sp-2;
border-radius: $r-control;
border: 1.5px solid rgba(255, 255, 255, .14);
background: rgba(255, 255, 255, .04);
cursor: pointer;
transform: scale(1);
}
.recovery__input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.recovery__label { font-size: 11px; font-weight: 600; color: $stage-paper; text-align: center; font-family: inherit; }
// 실제 사용 — 클릭·키보드로 고르면 그 카드만 즉시 밝아진다 (is-demo 없이 열었을 때)
.recovery__card:has(.recovery__input:checked) {
background: mix($color, $stage-ink, 26%);
border-color: $color;
box-shadow: 0 0 0 3px rgba($color, .3), 0 0 24px 12px rgba($color, .28);
transform: scale(1.04);
}
.recovery__card:has(.recovery__input:focus-visible) { outline: 2px solid $color; outline-offset: 2px; }
// 자동 재생 루프 — 카드 1·2·3 순서로 강조가 옮겨간다 (OTP 웨이브와 같은 기법, 카드마다 지연만 다르다)
.recovery.is-demo .recovery__card { animation: picker-cycle $duration $easing infinite; }
.recovery.is-demo .recovery__card:nth-child(1) { animation-delay: 0ms; }
.recovery.is-demo .recovery__card:nth-child(2) { animation-delay: 660ms; }
.recovery.is-demo .recovery__card:nth-child(3) { animation-delay: 1320ms; }
@keyframes picker-cycle {
0%, 6% { background: rgba(255, 255, 255, .04); border-color: rgba(255, 255, 255, .14); box-shadow: none; transform: scale(1); }
12%, 26% { background: mix($color, $stage-ink, 26%); border-color: $color; box-shadow: 0 0 0 3px rgba($color, .3), 0 0 24px 12px rgba($color, .28); transform: scale(1.04); }
32%, 100% { background: rgba(255, 255, 255, .04); border-color: rgba(255, 255, 255, .14); box-shadow: none; transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.recovery.is-demo .recovery__card { animation: none; }
}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);
}
.recovery-scene {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.recovery-scene__icon {
width: 24px;
height: 24px;
color: #2f6df6;
}
.recovery-scene__title {
margin: 0;
font-size: 13px;
font-weight: 700;
color: #ffffff;
font-family: inherit;
}
.recovery {
display: flex;
gap: 12px;
width: min(300px, 90vw);
}
.recovery__card {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 12px 8px;
border-radius: 12px;
border: 1.5px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.04);
cursor: pointer;
transform: scale(1);
}
.recovery__input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.recovery__label {
font-size: 11px;
font-weight: 600;
color: #ffffff;
text-align: center;
font-family: inherit;
}
.recovery__card:has(.recovery__input:checked) {
background: rgb(11.4666666667%, 16.9176470588%, 32.6274509804%);
border-color: #2f6df6;
box-shadow: 0 0 0 3px rgba(47, 109, 246, 0.3), 0 0 24px 12px rgba(47, 109, 246, 0.28);
transform: scale(1.04);
}
.recovery__card:has(.recovery__input:focus-visible) {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.recovery.is-demo .recovery__card {
animation: picker-cycle 2s ease-out infinite;
}
.recovery.is-demo .recovery__card:nth-child(1) {
animation-delay: 0ms;
}
.recovery.is-demo .recovery__card:nth-child(2) {
animation-delay: 660ms;
}
.recovery.is-demo .recovery__card:nth-child(3) {
animation-delay: 1320ms;
}
@keyframes picker-cycle {
0%, 6% {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.14);
box-shadow: none;
transform: scale(1);
}
12%, 26% {
background: rgb(11.4666666667%, 16.9176470588%, 32.6274509804%);
border-color: #2f6df6;
box-shadow: 0 0 0 3px rgba(47, 109, 246, 0.3), 0 0 24px 12px rgba(47, 109, 246, 0.28);
transform: scale(1.04);
}
32%, 100% {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.14);
box-shadow: none;
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.recovery.is-demo .recovery__card {
animation: none;
}
}import styles from "./RecoveryMethodPicker.module.scss";
export type RecoveryMethod = "email" | "text" | "question";
export type RecoveryMethodPickerProps = {
selected: RecoveryMethod;
onSelect: (method: RecoveryMethod) => void;
};
const OPTIONS: { value: RecoveryMethod; label: string }[] = [
{ value: "email", label: "Email" },
{ value: "text", label: "Text message" },
{ value: "question", label: "Security question" },
];
/** 이메일·문자·보안 질문 중 하나를 고르면 그 카드만 밝게 들린다 */
export default function RecoveryMethodPicker({ selected, onSelect }: RecoveryMethodPickerProps) {
return (
<div className={styles.scene}>
<span className={styles.icon} aria-hidden="true">
<svg viewBox="0 0 256 256">
<g fill="currentColor"><path d="M232 98.36c-1.27 38.56-33.33 69.64-71.91 69.64a71.7 71.7 0 0 1-26.92-5.17L120 176H96v24H72v24H40a8 8 0 0 1-8-8v-28.69a8 8 0 0 1 2.34-5.65l58.83-58.83A71.7 71.7 0 0 1 88 95.91c0-38.58 31.08-70.64 69.64-71.87A72 72 0 0 1 232 98.36" opacity=".2" /><path d="M216.57 39.43a80 80 0 0 0-132.66 81.35L28.69 176A15.86 15.86 0 0 0 24 187.31V216a16 16 0 0 0 16 16h32a8 8 0 0 0 8-8v-16h16a8 8 0 0 0 8-8v-16h16a8 8 0 0 0 5.66-2.34l9.56-9.57A79.7 79.7 0 0 0 160 176h.1a80 80 0 0 0 56.47-136.57M224 98.1c-1.09 34.09-29.75 61.86-63.89 61.9H160a63.7 63.7 0 0 1-23.65-4.51a8 8 0 0 0-8.84 1.68L116.69 168H96a8 8 0 0 0-8 8v16H72a8 8 0 0 0-8 8v16H40v-28.69l58.83-58.82a8 8 0 0 0 1.68-8.84A63.7 63.7 0 0 1 96 95.92c0-34.14 27.81-62.8 61.9-63.89A64 64 0 0 1 224 98.1M192 76a12 12 0 1 1-12-12a12 12 0 0 1 12 12" /></g>
</svg>
</span>
<p className={styles.title}>Choose a recovery method</p>
<div className={styles.group} role="radiogroup" aria-label="Choose recovery method">
{OPTIONS.map((opt) => (
<label key={opt.value} className={`${styles.card} ${selected === opt.value ? styles.isSelected : ""}`}>
<input
className={styles.input}
type="radio"
name="recovery"
value={opt.value}
checked={selected === opt.value}
onChange={() => onSelect(opt.value)}
/>
<span className={styles.label}>{opt.label}</span>
</label>
))}
</div>
</div>
);
}05 · 계정 복구 방법 선택 카드 — 측정 누적면적 23.378% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
비밀번호를 잊었을 때 복구 수단을 고르는 첫 화면이나 계정 잠금 해제 전 확인 단계에 씁니다.
.recovery__card:has(.recovery__input:checked) {
background: mix($color, $stage-ink, 26%);
border-color: $color;
box-shadow: 0 0 0 3px rgba($color, .3), 0 0 24px 12px rgba($color, .28);
transform: scale(1.04);
}
.recovery__card:has(.recovery__input:focus-visible) { outline: 2px solid $color; outline-offset: 2px; }
.recovery.is-demo .recovery__card { animation: picker-cycle $duration $easing infinite; }
.recovery.is-demo .recovery__card:nth-child(1) { animation-delay: 0ms; }
.recovery.is-demo .recovery__card:nth-child(2) { animation-delay: 660ms; }
.recovery.is-demo .recovery__card:nth-child(3) { animation-delay: 1320ms; }
06온보딩 위저드 스텝 카드
어떤 움직임인가
인증이 끝난 뒤 프로필을 채우는 카드로, 다음 버튼을 누르면 지금 카드가 왼쪽으로 밀려나며 다음 카드가 파스텔 색으로 슬라이드해 들어옵니다.

<main class="stage">
<div class="wizard is-demo">
<div class="wizard__viewport">
<div class="wizard__track">
<div class="wizard__step wizard__step--pink">
<span class="wizard__icon" aria-hidden="true">
<svg viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 128a95.76 95.76 0 0 1-31.8 71.37A72 72 0 0 0 128 160a40 40 0 1 0-40-40a40 40 0 0 0 40 40a72 72 0 0 0-64.2 39.37A96 96 0 1 1 224 128" opacity=".2"/><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24M74.08 197.5a64 64 0 0 1 107.84 0a87.83 87.83 0 0 1-107.84 0M96 120a32 32 0 1 1 32 32a32 32 0 0 1-32-32m97.76 66.41a79.66 79.66 0 0 0-36.06-28.75a48 48 0 1 0-59.4 0a79.66 79.66 0 0 0-36.06 28.75a88 88 0 1 1 131.52 0"/></g></svg>
</span>
<p class="wizard__label">Add a photo</p>
</div>
<div class="wizard__step wizard__step--lilac">
<span class="wizard__icon" aria-hidden="true">
<svg viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 128a95.76 95.76 0 0 1-31.8 71.37A72 72 0 0 0 128 160a40 40 0 1 0-40-40a40 40 0 0 0 40 40a72 72 0 0 0-64.2 39.37A96 96 0 1 1 224 128" opacity=".2"/><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24M74.08 197.5a64 64 0 0 1 107.84 0a87.83 87.83 0 0 1-107.84 0M96 120a32 32 0 1 1 32 32a32 32 0 0 1-32-32m97.76 66.41a79.66 79.66 0 0 0-36.06-28.75a48 48 0 1 0-59.4 0a79.66 79.66 0 0 0-36.06 28.75a88 88 0 1 1 131.52 0"/></g></svg>
</span>
<p class="wizard__label">Pick your username</p>
</div>
</div>
</div>
<div class="wizard__dots">
<span class="wizard__dot"></span>
<span class="wizard__dot"></span>
</div>
<button class="wizard__next" type="button">Next</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 다음 단계로 슬라이드했다 되돌아오는 한 주기
$easing: $ease-spring;
$color: $subject-pink; // 밝고 귀여운 파스텔 축
@include stage($stage-yellow);
.wizard { display: flex; flex-direction: column; align-items: center; gap: $sp-4; }
.wizard__viewport {
width: min(200px, 78vw);
height: 150px;
overflow: hidden;
border-radius: $r-card;
box-shadow: $shadow-raised;
}
.wizard__track {
display: flex;
width: 200%;
height: 100%;
transition: transform $dur-base $easing;
}
.wizard__step {
flex: 0 0 50%;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-2;
padding: $sp-4;
}
.wizard__step--pink { background: $subject-pink; }
.wizard__step--lilac { background: $subject-lilac; }
.wizard__icon { width: 36px; height: 36px; color: $stage-paper; }
.wizard__label { margin: 0; font-size: 13px; font-weight: 700; color: $stage-ink; font-family: inherit; text-align: center; }
.wizard__dots { display: flex; gap: $sp-2; }
.wizard__dot {
width: 8px; height: 8px;
border-radius: 50%;
background: rgba(23, 20, 26, .25);
transition: background $dur-base $easing, transform $dur-base $easing;
}
.wizard__dot.is-active { background: $stage-ink; transform: scale(1.2); }
.wizard__next {
padding: $sp-2 $sp-4;
border: none;
border-radius: $r-pill;
background: $stage-ink;
color: $stage-paper;
font-weight: 700;
font-size: 12px;
font-family: inherit;
cursor: pointer;
}
.wizard__next:hover, .wizard__next:focus-visible { background: lighten($stage-ink, 12%); }
// 자동 재생 루프 — 다음으로 넘어갔다가 다시 첫 화면으로 돌아온다 (실제 클릭은 index.html 의 스크립트가 처리)
.wizard.is-demo .wizard__track { animation: wiz-track $duration $easing infinite; }
.wizard.is-demo .wizard__dot:nth-child(1) { animation: wiz-dot1 $duration $easing infinite; }
.wizard.is-demo .wizard__dot:nth-child(2) { animation: wiz-dot2 $duration $easing infinite; }
@keyframes wiz-track {
0%, 8% { transform: translateX(0%); }
23%, 77% { transform: translateX(-50%); }
92%, 100% { transform: translateX(0%); }
}
@keyframes wiz-dot1 {
0%, 8% { background: $stage-ink; transform: scale(1.2); }
23%, 77% { background: rgba(23, 20, 26, .25); transform: scale(1); }
92%, 100% { background: $stage-ink; transform: scale(1.2); }
}
@keyframes wiz-dot2 {
0%, 8% { background: rgba(23, 20, 26, .25); transform: scale(1); }
23%, 77% { background: $stage-ink; transform: scale(1.2); }
92%, 100% { background: rgba(23, 20, 26, .25); transform: scale(1); }
}
// 폰 뷰포트(320×200) — 세로 합(뷰포트 150 + 점 + 버튼 + 간격 2)이 174px 을 넘어 BD9 가 깨졌다. 카드 높이와 간격만 줄인다
@media (max-height: 240px) {
.wizard { gap: $sp-2; }
.wizard__viewport { height: 96px; }
.wizard__icon { width: 28px; height: 28px; }
}
@media (prefers-reduced-motion: reduce) {
.wizard__track, .wizard__dot { transition: none; }
.wizard.is-demo .wizard__track,
.wizard.is-demo .wizard__dot:nth-child(1),
.wizard.is-demo .wizard__dot:nth-child(2) { animation: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wizard {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.wizard__viewport {
width: min(200px, 78vw);
height: 150px;
overflow: hidden;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.wizard__track {
display: flex;
width: 200%;
height: 100%;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.wizard__step {
flex: 0 0 50%;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 16px;
}
.wizard__step--pink {
background: #ff9ebb;
}
.wizard__step--lilac {
background: #b9a0ff;
}
.wizard__icon {
width: 36px;
height: 36px;
color: #ffffff;
}
.wizard__label {
margin: 0;
font-size: 13px;
font-weight: 700;
color: #17141a;
font-family: inherit;
text-align: center;
}
.wizard__dots {
display: flex;
gap: 8px;
}
.wizard__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.25);
transition: background 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.wizard__dot.is-active {
background: #17141a;
transform: scale(1.2);
}
.wizard__next {
padding: 8px 16px;
border: none;
border-radius: 999px;
background: #17141a;
color: #ffffff;
font-weight: 700;
font-size: 12px;
font-family: inherit;
cursor: pointer;
}
.wizard__next:hover, .wizard__next:focus-visible {
background: rgb(21.0196078431%, 18.2779198636%, 23.7612958227%);
}
.wizard.is-demo .wizard__track {
animation: wiz-track 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.wizard.is-demo .wizard__dot:nth-child(1) {
animation: wiz-dot1 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.wizard.is-demo .wizard__dot:nth-child(2) {
animation: wiz-dot2 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes wiz-track {
0%, 8% {
transform: translateX(0%);
}
23%, 77% {
transform: translateX(-50%);
}
92%, 100% {
transform: translateX(0%);
}
}
@keyframes wiz-dot1 {
0%, 8% {
background: #17141a;
transform: scale(1.2);
}
23%, 77% {
background: rgba(23, 20, 26, 0.25);
transform: scale(1);
}
92%, 100% {
background: #17141a;
transform: scale(1.2);
}
}
@keyframes wiz-dot2 {
0%, 8% {
background: rgba(23, 20, 26, 0.25);
transform: scale(1);
}
23%, 77% {
background: #17141a;
transform: scale(1.2);
}
92%, 100% {
background: rgba(23, 20, 26, 0.25);
transform: scale(1);
}
}
@media (max-height: 240px) {
.wizard {
gap: 8px;
}
.wizard__viewport {
height: 96px;
}
.wizard__icon {
width: 28px;
height: 28px;
}
}
@media (prefers-reduced-motion: reduce) {
.wizard__track, .wizard__dot {
transition: none;
}
.wizard.is-demo .wizard__track,
.wizard.is-demo .wizard__dot:nth-child(1),
.wizard.is-demo .wizard__dot:nth-child(2) {
animation: none;
}
}(function(){
var track = document.querySelector('.wizard__track');
var dots = Array.prototype.slice.call(document.querySelectorAll('.wizard__dot'));
var btn = document.querySelector('.wizard__next');
var step = 0;
btn.addEventListener('click', function(){
step = step === 0 ? 1 : 0;
track.style.transform = 'translateX(' + (step === 0 ? '0%' : '-50%') + ')';
dots.forEach(function(d, i){ d.classList.toggle('is-active', i === step); });
btn.textContent = step === 0 ? 'Next' : 'Back';
});
}());import styles from "./OnboardingWizardStep.module.scss";
export type WizardStep = { label: string };
export type OnboardingWizardStepProps = {
steps: WizardStep[];
step: number;
onNext?: () => void;
};
const ICON = (
<svg viewBox="0 0 256 256">
<g fill="currentColor"><path d="M224 128a95.76 95.76 0 0 1-31.8 71.37A72 72 0 0 0 128 160a40 40 0 1 0-40-40a40 40 0 0 0 40 40a72 72 0 0 0-64.2 39.37A96 96 0 1 1 224 128" opacity=".2" /><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24M74.08 197.5a64 64 0 0 1 107.84 0a87.83 87.83 0 0 1-107.84 0M96 120a32 32 0 1 1 32 32a32 32 0 0 1-32-32m97.76 66.41a79.66 79.66 0 0 0-36.06-28.75a48 48 0 1 0-59.4 0a79.66 79.66 0 0 0-36.06 28.75a88 88 0 1 1 131.52 0" /></g>
</svg>
);
/** 인증 직후 프로필 설정을 한 단계씩 진행한다 — Next 를 누르면 다음 파스텔 카드로 넘어간다 */
export default function OnboardingWizardStep({ steps, step, onNext }: OnboardingWizardStepProps) {
const count = steps.length;
return (
<div className={styles.wizard}>
<div className={styles.viewport}>
<div
className={styles.track}
style={{ width: `${count * 100}%`, transform: `translateX(-${(100 / count) * step}%)` }}
>
{steps.map((s, i) => (
<div
key={i}
className={`${styles.step} ${i % 2 === 0 ? styles.pink : styles.lilac}`}
style={{ flexBasis: `${100 / count}%` }}
>
<span className={styles.icon} aria-hidden="true">{ICON}</span>
<p className={styles.label}>{s.label}</p>
</div>
))}
</div>
</div>
<div className={styles.dots}>
{steps.map((_, i) => (
<span key={i} className={`${styles.dot} ${i === step ? styles.isActive : ""}`} />
))}
</div>
<button className={styles.next} type="button" onClick={onNext} disabled={step >= count - 1}>
{step >= count - 1 ? "Done" : "Next"}
</button>
</div>
);
}06 · 온보딩 위저드 스텝 카드 — 측정 누적면적 20.839% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
회원가입 인증 직후 이어지는 프로필 설정 단계나 첫 실행 온보딩의 정보 입력 카드에 씁니다.
(function(){
var track = document.querySelector('.wizard__track');
var dots = Array.prototype.slice.call(document.querySelectorAll('.wizard__dot'));
var btn = document.querySelector('.wizard__next');
var step = 0;
btn.addEventListener('click', function(){
step = step === 0 ? 1 : 0;
track.style.transform = 'translateX(' + (step === 0 ? '0%' : '-50%') + ')';
dots.forEach(function(d, i){ d.classList.toggle('is-active', i === step); });
btn.textContent = step === 0 ? 'Next' : 'Back';
});
}());
07연결된 계정 목록
무엇이 보이나
구글·깃허브 같은 로그인 수단이 줄마다 나열되고, 연결 스위치를 누르면 토글이 미끄러지듯 넘어가며 줄 배경이 옅은 색으로 물듭니다.

<main class="stage">
<div class="accounts-card">
<h2 class="accounts-card__title">Linked accounts</h2>
<div class="accounts is-demo">
<label class="accounts__row">
<span class="accounts__icon" aria-hidden="true">
<svg viewBox="0 0 256 256"><g fill="currentColor"><path d="m218.34 119.6l-34.74 34.74a46.58 46.58 0 0 1-44.31 12.26c-.31.34-.62.67-.95 1l-34.74 34.74a46.63 46.63 0 1 1-65.94-65.94l34.74-34.74a46.6 46.6 0 0 1 44.31-12.26c.31-.34.62-.67 1-1l34.69-34.74a46.63 46.63 0 0 1 65.94 65.94" opacity=".2"/><path d="M240 88.23a54.43 54.43 0 0 1-16 37L189.25 160a54.27 54.27 0 0 1-38.63 16h-.05A54.63 54.63 0 0 1 96 119.84a8 8 0 0 1 16 .45A38.62 38.62 0 0 0 150.58 160a38.4 38.4 0 0 0 27.31-11.31l34.75-34.75a38.63 38.63 0 0 0-54.63-54.63l-11 11A8 8 0 0 1 135.7 59l11-11a54.65 54.65 0 0 1 77.3 0a54.86 54.86 0 0 1 16 40.23m-131 97.43l-11 11A38.4 38.4 0 0 1 70.6 208a38.63 38.63 0 0 1-27.29-65.94L78 107.31a38.63 38.63 0 0 1 66 28.4a8 8 0 0 0 7.78 8.22h.22a8 8 0 0 0 8-7.78A54.86 54.86 0 0 0 144 96a54.65 54.65 0 0 0-77.27 0L32 130.75A54.62 54.62 0 0 0 70.56 224a54.28 54.28 0 0 0 38.64-16l11-11a8 8 0 0 0-11.2-11.34"/></g></svg>
</span>
<span class="accounts__label">Google</span>
<span class="accounts__switch">
<input class="accounts__input" type="checkbox">
<span class="accounts__track"><span class="accounts__knob"></span></span>
</span>
</label>
<label class="accounts__row">
<span class="accounts__icon" aria-hidden="true">
<svg viewBox="0 0 256 256"><g fill="currentColor"><path d="m218.34 119.6l-34.74 34.74a46.58 46.58 0 0 1-44.31 12.26c-.31.34-.62.67-.95 1l-34.74 34.74a46.63 46.63 0 1 1-65.94-65.94l34.74-34.74a46.6 46.6 0 0 1 44.31-12.26c.31-.34.62-.67 1-1l34.69-34.74a46.63 46.63 0 0 1 65.94 65.94" opacity=".2"/><path d="M240 88.23a54.43 54.43 0 0 1-16 37L189.25 160a54.27 54.27 0 0 1-38.63 16h-.05A54.63 54.63 0 0 1 96 119.84a8 8 0 0 1 16 .45A38.62 38.62 0 0 0 150.58 160a38.4 38.4 0 0 0 27.31-11.31l34.75-34.75a38.63 38.63 0 0 0-54.63-54.63l-11 11A8 8 0 0 1 135.7 59l11-11a54.65 54.65 0 0 1 77.3 0a54.86 54.86 0 0 1 16 40.23m-131 97.43l-11 11A38.4 38.4 0 0 1 70.6 208a38.63 38.63 0 0 1-27.29-65.94L78 107.31a38.63 38.63 0 0 1 66 28.4a8 8 0 0 0 7.78 8.22h.22a8 8 0 0 0 8-7.78A54.86 54.86 0 0 0 144 96a54.65 54.65 0 0 0-77.27 0L32 130.75A54.62 54.62 0 0 0 70.56 224a54.28 54.28 0 0 0 38.64-16l11-11a8 8 0 0 0-11.2-11.34"/></g></svg>
</span>
<span class="accounts__label">GitHub</span>
<span class="accounts__switch">
<input class="accounts__input" type="checkbox">
<span class="accounts__track"><span class="accounts__knob"></span></span>
</span>
</label>
<label class="accounts__row">
<span class="accounts__icon" aria-hidden="true">
<svg viewBox="0 0 256 256"><g fill="currentColor"><path d="m218.34 119.6l-34.74 34.74a46.58 46.58 0 0 1-44.31 12.26c-.31.34-.62.67-.95 1l-34.74 34.74a46.63 46.63 0 1 1-65.94-65.94l34.74-34.74a46.6 46.6 0 0 1 44.31-12.26c.31-.34.62-.67 1-1l34.69-34.74a46.63 46.63 0 0 1 65.94 65.94" opacity=".2"/><path d="M240 88.23a54.43 54.43 0 0 1-16 37L189.25 160a54.27 54.27 0 0 1-38.63 16h-.05A54.63 54.63 0 0 1 96 119.84a8 8 0 0 1 16 .45A38.62 38.62 0 0 0 150.58 160a38.4 38.4 0 0 0 27.31-11.31l34.75-34.75a38.63 38.63 0 0 0-54.63-54.63l-11 11A8 8 0 0 1 135.7 59l11-11a54.65 54.65 0 0 1 77.3 0a54.86 54.86 0 0 1 16 40.23m-131 97.43l-11 11A38.4 38.4 0 0 1 70.6 208a38.63 38.63 0 0 1-27.29-65.94L78 107.31a38.63 38.63 0 0 1 66 28.4a8 8 0 0 0 7.78 8.22h.22a8 8 0 0 0 8-7.78A54.86 54.86 0 0 0 144 96a54.65 54.65 0 0 0-77.27 0L32 130.75A54.62 54.62 0 0 0 70.56 224a54.28 54.28 0 0 0 38.64-16l11-11a8 8 0 0 0-11.2-11.34"/></g></svg>
</span>
<span class="accounts__label">Apple</span>
<span class="accounts__switch">
<input class="accounts__input" type="checkbox">
<span class="accounts__track"><span class="accounts__knob"></span></span>
</span>
</label>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 스위치가 순서대로 켜지는 한 바퀴
$easing: $ease-out;
$color: $subject-sky; // 연결 상태 강조 색 (밝고 상큼한 축)
@include stage($stage-paper);
.accounts-card {
width: min(240px, 86vw);
padding: $sp-3;
border-radius: $r-card;
background: $stage-paper;
border: 1.5px solid #e6e2d8;
box-shadow: $shadow-press;
}
.accounts-card__title {
margin: 0 0 $sp-3;
font-size: 13px;
font-weight: 700;
color: $stage-ink;
font-family: inherit;
}
.accounts {
display: flex;
flex-direction: column;
gap: $sp-2;
}
.accounts__row {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-sm;
cursor: pointer;
background: transparent;
}
.accounts__icon { width: 16px; height: 16px; flex: 0 0 auto; color: #9a927e; }
.accounts__label { flex: 1 1 auto; font-size: 12px; font-weight: 600; color: $stage-ink; }
.accounts__switch { position: relative; flex: 0 0 auto; width: 32px; height: 18px; }
.accounts__input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.accounts__track {
position: absolute;
inset: 0;
border-radius: $r-pill;
background: #d8d2c4;
}
.accounts__knob {
position: absolute;
top: 2px; left: 2px;
width: 14px; height: 14px;
border-radius: 50%;
background: $stage-paper;
box-shadow: $shadow-press;
}
// 실제 사용 — 클릭해서 켜면 즉시 반영 (is-demo 없이 열었을 때)
.accounts__input:checked ~ .accounts__track { background: $color; }
.accounts__input:checked ~ .accounts__track .accounts__knob { transform: translateX(14px); }
.accounts__input:focus-visible ~ .accounts__track { outline: 2px solid $color; outline-offset: 2px; }
.accounts__row:has(.accounts__input:checked) { background: rgba($subject-mint, .18); }
// 자동 재생 — 행마다 순서대로 연결이 켜졌다 꺼진다 (같은 keyframe, delay 로 어긋나게)
.accounts.is-demo .accounts__row { animation: acct-row-cycle $duration $easing infinite; }
.accounts.is-demo .accounts__track { animation: acct-track-cycle $duration $easing infinite; }
.accounts.is-demo .accounts__knob { animation: acct-knob-cycle $duration $easing infinite; }
.accounts.is-demo .accounts__row:nth-child(1),
.accounts.is-demo .accounts__row:nth-child(1) .accounts__track,
.accounts.is-demo .accounts__row:nth-child(1) .accounts__knob { animation-delay: 0ms; }
.accounts.is-demo .accounts__row:nth-child(2),
.accounts.is-demo .accounts__row:nth-child(2) .accounts__track,
.accounts.is-demo .accounts__row:nth-child(2) .accounts__knob { animation-delay: 500ms; }
.accounts.is-demo .accounts__row:nth-child(3),
.accounts.is-demo .accounts__row:nth-child(3) .accounts__track,
.accounts.is-demo .accounts__row:nth-child(3) .accounts__knob { animation-delay: 1000ms; }
@keyframes acct-row-cycle {
0%, 8% { background: transparent; }
14%, 36% { background: rgba($subject-mint, .22); }
42%, 100% { background: transparent; }
}
@keyframes acct-track-cycle {
0%, 8% { background: #d8d2c4; }
14%, 36% { background: $color; }
42%, 100% { background: #d8d2c4; }
}
@keyframes acct-knob-cycle {
0%, 8% { transform: translateX(0); }
14%, 36% { transform: translateX(14px); }
42%, 100% { transform: translateX(0); }
}
@media (prefers-reduced-motion: reduce) {
.accounts.is-demo .accounts__row,
.accounts.is-demo .accounts__track,
.accounts.is-demo .accounts__knob { animation: none; }
}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);
}
.accounts-card {
width: min(240px, 86vw);
padding: 12px;
border-radius: 16px;
background: #ffffff;
border: 1.5px solid #e6e2d8;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.accounts-card__title {
margin: 0 0 12px;
font-size: 13px;
font-weight: 700;
color: #17141a;
font-family: inherit;
}
.accounts {
display: flex;
flex-direction: column;
gap: 8px;
}
.accounts__row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: 8px;
cursor: pointer;
background: transparent;
}
.accounts__icon {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #9a927e;
}
.accounts__label {
flex: 1 1 auto;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.accounts__switch {
position: relative;
flex: 0 0 auto;
width: 32px;
height: 18px;
}
.accounts__input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.accounts__track {
position: absolute;
inset: 0;
border-radius: 999px;
background: #d8d2c4;
}
.accounts__knob {
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 50%;
background: #ffffff;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.accounts__input:checked ~ .accounts__track {
background: #6ec8ff;
}
.accounts__input:checked ~ .accounts__track .accounts__knob {
transform: translateX(14px);
}
.accounts__input:focus-visible ~ .accounts__track {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.accounts__row:has(.accounts__input:checked) {
background: rgba(76, 212, 166, 0.18);
}
.accounts.is-demo .accounts__row {
animation: acct-row-cycle 2s ease-out infinite;
}
.accounts.is-demo .accounts__track {
animation: acct-track-cycle 2s ease-out infinite;
}
.accounts.is-demo .accounts__knob {
animation: acct-knob-cycle 2s ease-out infinite;
}
.accounts.is-demo .accounts__row:nth-child(1),
.accounts.is-demo .accounts__row:nth-child(1) .accounts__track,
.accounts.is-demo .accounts__row:nth-child(1) .accounts__knob {
animation-delay: 0ms;
}
.accounts.is-demo .accounts__row:nth-child(2),
.accounts.is-demo .accounts__row:nth-child(2) .accounts__track,
.accounts.is-demo .accounts__row:nth-child(2) .accounts__knob {
animation-delay: 500ms;
}
.accounts.is-demo .accounts__row:nth-child(3),
.accounts.is-demo .accounts__row:nth-child(3) .accounts__track,
.accounts.is-demo .accounts__row:nth-child(3) .accounts__knob {
animation-delay: 1000ms;
}
@keyframes acct-row-cycle {
0%, 8% {
background: transparent;
}
14%, 36% {
background: rgba(76, 212, 166, 0.22);
}
42%, 100% {
background: transparent;
}
}
@keyframes acct-track-cycle {
0%, 8% {
background: #d8d2c4;
}
14%, 36% {
background: #6ec8ff;
}
42%, 100% {
background: #d8d2c4;
}
}
@keyframes acct-knob-cycle {
0%, 8% {
transform: translateX(0);
}
14%, 36% {
transform: translateX(14px);
}
42%, 100% {
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.accounts.is-demo .accounts__row,
.accounts.is-demo .accounts__track,
.accounts.is-demo .accounts__knob {
animation: none;
}
}import styles from "./LinkedAccountsList.module.scss";
export type LinkedAccount = {
id: string;
label: string;
connected: boolean;
};
export type LinkedAccountsListProps = {
accounts: LinkedAccount[];
onToggle?: (id: string, connected: boolean) => void;
};
const linkIcon = (
<svg viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor">
<path d="m218.34 119.6l-34.74 34.74a46.58 46.58 0 0 1-44.31 12.26c-.31.34-.62.67-.95 1l-34.74 34.74a46.63 46.63 0 1 1-65.94-65.94l34.74-34.74a46.6 46.6 0 0 1 44.31-12.26c.31-.34.62-.67 1-1l34.69-34.74a46.63 46.63 0 0 1 65.94 65.94" opacity=".2" />
<path d="M240 88.23a54.43 54.43 0 0 1-16 37L189.25 160a54.27 54.27 0 0 1-38.63 16h-.05A54.63 54.63 0 0 1 96 119.84a8 8 0 0 1 16 .45A38.62 38.62 0 0 0 150.58 160a38.4 38.4 0 0 0 27.31-11.31l34.75-34.75a38.63 38.63 0 0 0-54.63-54.63l-11 11A8 8 0 0 1 135.7 59l11-11a54.65 54.65 0 0 1 77.3 0a54.86 54.86 0 0 1 16 40.23m-131 97.43l-11 11A38.4 38.4 0 0 1 70.6 208a38.63 38.63 0 0 1-27.29-65.94L78 107.31a38.63 38.63 0 0 1 66 28.4a8 8 0 0 0 7.78 8.22h.22a8 8 0 0 0 8-7.78A54.86 54.86 0 0 0 144 96a54.65 54.65 0 0 0-77.27 0L32 130.75A54.62 54.62 0 0 0 70.56 224a54.28 54.28 0 0 0 38.64-16l11-11a8 8 0 0 0-11.2-11.34" />
</g>
</svg>
);
/** 소셜 로그인 연결 목록 — 스위치를 켜고 끄면 부모가 실제 연결 상태를 바꾼다 */
export default function LinkedAccountsList({ accounts, onToggle }: LinkedAccountsListProps) {
return (
<div className={styles.card}>
<h2 className={styles.title}>Linked accounts</h2>
<div className={styles.list}>
{accounts.map((a) => (
<label key={a.id} className={a.connected ? `${styles.row} ${styles.rowOn}` : styles.row}>
<span className={styles.icon}>{linkIcon}</span>
<span className={styles.label}>{a.label}</span>
<span className={styles.switch}>
<input
type="checkbox"
className={styles.input}
checked={a.connected}
onChange={(e: any) => onToggle?.(a.id, e.target.checked)}
/>
<span className={styles.track}>
<span className={styles.knob} />
</span>
</span>
</label>
))}
</div>
</div>
);
}07 · 연결된 계정 목록 — 측정 누적면적 12.77% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
설정 화면의 소셜 로그인 연결 관리나 여러 계정을 하나로 묶는 통합 로그인 대시보드에 씁니다.
.accounts__input:checked ~ .accounts__track { background: $color; }
.accounts__input:checked ~ .accounts__track .accounts__knob { transform: translateX(14px); }
.accounts__input:focus-visible ~ .accounts__track { outline: 2px solid $color; outline-offset: 2px; }
.accounts__row:has(.accounts__input:checked) { background: rgba($subject-mint, .18); }
@keyframes acct-knob-cycle {
0%, 8% { transform: translateX(0); }
14%, 36% { transform: translateX(14px); }
42%, 100% { transform: translateX(0); }
}
08세션 만료 임박 배너
화면에서 일어나는 일
화면 위쪽에서 배너가 미끄러져 내려오며 남은 시간이 초 단위로 줄어들고, 연장 버튼을 누르면 남은 시간이 다시 30초로 채워집니다.

<main class="stage">
<div class="timeout-wrap">
<div class="timeout-wrap__page">
<span class="timeout-wrap__line"></span>
<span class="timeout-wrap__line timeout-wrap__line--short"></span>
<span class="timeout-wrap__line"></span>
</div>
<div class="timeout-banner is-demo" role="alert">
<span class="timeout-banner__icon" aria-hidden="true">
<svg viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg>
</span>
<p class="timeout-banner__text">Your session ends in <span class="timeout-banner__count">30</span>s</p>
<button class="timeout-banner__extend" type="button">Extend</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 배너가 내려왔다 올라가는 한 바퀴
$easing: $ease-spring;
$color: $subject-blue; // 신뢰감 있는 차분한 강조 색
@include stage($stage-yellow);
.timeout-wrap {
position: relative;
width: min(280px, 90vw);
height: 132px;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
}
.timeout-wrap__page {
position: absolute;
inset: 0;
padding: $sp-4;
padding-top: $sp-12;
background: $stage-paper;
display: flex;
flex-direction: column;
gap: $sp-3;
}
.timeout-wrap__line { display: block; height: 8px; border-radius: $r-xs; background: #e6e2d8; }
.timeout-wrap__line--short { width: 55%; }
.timeout-banner {
position: absolute;
top: 0; left: 0; right: 0;
display: flex;
align-items: center;
gap: $sp-3;
padding: $sp-3 $sp-4;
background: $stage-paper;
border-bottom: 1.5px solid #e6e2d8;
border-radius: 0 0 $r-card $r-card;
box-shadow: $shadow-raised;
transform: translateY(-100%);
}
.timeout-banner__icon { width: 22px; height: 22px; flex: 0 0 auto; color: $color; }
.timeout-banner__text { flex: 1 1 auto; margin: 0; font-size: 12px; font-weight: 600; color: $stage-ink; font-family: inherit; }
.timeout-banner__count { color: $color; font-weight: 700; }
.timeout-banner__extend {
flex: 0 0 auto;
padding: $sp-1 $sp-3;
border: none;
border-radius: $r-pill;
background: $color;
color: $stage-paper;
font-size: 11px;
font-weight: 700;
font-family: inherit;
cursor: pointer;
}
.timeout-banner__extend:hover, .timeout-banner__extend:focus-visible { background: darken($color, 10%); }
// 자동 재생 — 배너가 내려와 머물다 다시 올라간다 (텍스트 숫자는 실제 JS 타이머, 이 애니메이션과 무관)
.timeout-banner.is-demo { animation: banner-cycle $duration $easing infinite; }
@keyframes banner-cycle {
0%, 8% { transform: translateY(-100%); }
23%, 77% { transform: translateY(0); }
92%, 100% { transform: translateY(-100%); }
}
@media (prefers-reduced-motion: reduce) {
.timeout-banner.is-demo { animation: none; transform: translateY(0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.timeout-wrap {
position: relative;
width: min(280px, 90vw);
height: 132px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.timeout-wrap__page {
position: absolute;
inset: 0;
padding: 16px;
padding-top: 48px;
background: #ffffff;
display: flex;
flex-direction: column;
gap: 12px;
}
.timeout-wrap__line {
display: block;
height: 8px;
border-radius: 4px;
background: #e6e2d8;
}
.timeout-wrap__line--short {
width: 55%;
}
.timeout-banner {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: #ffffff;
border-bottom: 1.5px solid #e6e2d8;
border-radius: 0 0 16px 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: translateY(-100%);
}
.timeout-banner__icon {
width: 22px;
height: 22px;
flex: 0 0 auto;
color: #2f6df6;
}
.timeout-banner__text {
flex: 1 1 auto;
margin: 0;
font-size: 12px;
font-weight: 600;
color: #17141a;
font-family: inherit;
}
.timeout-banner__count {
color: #2f6df6;
font-weight: 700;
}
.timeout-banner__extend {
flex: 0 0 auto;
padding: 4px 12px;
border: none;
border-radius: 999px;
background: #2f6df6;
color: #ffffff;
font-size: 11px;
font-weight: 700;
font-family: inherit;
cursor: pointer;
}
.timeout-banner__extend:hover, .timeout-banner__extend:focus-visible {
background: rgb(3.9360260233%, 31.0508719617%, 90.965934761%);
}
.timeout-banner.is-demo {
animation: banner-cycle 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes banner-cycle {
0%, 8% {
transform: translateY(-100%);
}
23%, 77% {
transform: translateY(0);
}
92%, 100% {
transform: translateY(-100%);
}
}
@media (prefers-reduced-motion: reduce) {
.timeout-banner.is-demo {
animation: none;
transform: translateY(0);
}
}(function(){
var cnt = document.querySelector('.timeout-banner__count'),
btn = document.querySelector('.timeout-banner__extend'),
n = 30,
timer = setInterval(function(){
n--; if (n < 0) n = 0; cnt.textContent = n;
}, 1000);
btn.addEventListener('click', function(){
n = 30; cnt.textContent = n;
});
}());import { useEffect, useState } from "react";
import styles from "./SessionTimeoutBanner.module.scss";
export type SessionTimeoutBannerProps = {
/** 세션 만료까지 남은 초 (기본 30) */
seconds?: number;
onExtend?: () => void;
};
/** 마운트·연장마다 seconds 초를 세고, 0 이 되기 전에 Extend 를 누르면 다시 처음부터 센다 */
export default function SessionTimeoutBanner({ seconds = 30, onExtend }: SessionTimeoutBannerProps) {
const [left, setLeft] = useState(seconds);
useEffect(() => {
if (left <= 0) return;
const id = setInterval(() => setLeft((n) => Math.max(0, n - 1)), 1000);
return () => clearInterval(id);
}, [left]);
const extend = () => {
setLeft(seconds);
onExtend?.();
};
return (
<div className={styles.banner} role="alert">
<span className={styles.icon} aria-hidden="true">
<svg viewBox="0 0 256 256">
<g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2" /><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z" /></g>
</svg>
</span>
<p className={styles.text}>Your session ends in <span className={styles.count}>{left}</span>s</p>
<button className={styles.extend} type="button" onClick={extend}>Extend</button>
</div>
);
}08 · 세션 만료 임박 배너 — 측정 누적면적 14.106% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
은행·결제 앱의 자동 로그아웃 전 경고나 관리자 콘솔의 유휴 세션 알림에 씁니다.
(function(){
var cnt = document.querySelector('.timeout-banner__count'),
btn = document.querySelector('.timeout-banner__extend'),
n = 30,
timer = setInterval(function(){
n--; if (n < 0) n = 0; cnt.textContent = n;
}, 1000);
btn.addEventListener('click', function(){
n = 30; cnt.textContent = n;
});
}());
09로그아웃 확인 카드
어떤 움직임인가
확인 카드가 뜨는 순간 살짝 흔들려 눈길을 끌고, 정말 나가기를 누르면 카드 배경이 어두운 빨강으로 짙게 물듭니다.

<main class="stage">
<div class="signout-card is-demo">
<span class="signout-card__icon" aria-hidden="true">
<svg viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 56v144a16 16 0 0 1-16 16H48V40h160a16 16 0 0 1 16 16" opacity=".2"/><path d="M120 216a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V40a8 8 0 0 1 8-8h64a8 8 0 0 1 0 16H56v160h56a8 8 0 0 1 8 8m109.66-93.66l-40-40a8 8 0 0 0-11.32 11.32L204.69 120H112a8 8 0 0 0 0 16h92.69l-26.35 26.34a8 8 0 0 0 11.32 11.32l40-40a8 8 0 0 0 0-11.32"/></g></svg>
</span>
<h2 class="signout-card__title">Sign out of this device?</h2>
<p class="signout-card__desc">You'll need to sign in again to continue.</p>
<div class="signout-card__actions">
<button class="signout-card__btn signout-card__btn--cancel" type="button">Cancel</button>
<button class="signout-card__btn signout-card__btn--danger" type="button">Sign out</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-loop; // 흔들림 → 확인 상태 전환 한 바퀴
$easing: $ease-spring;
$color: $error; // 위험 강조 색
@include stage($stage-ink);
$card-bg: lighten($stage-ink, 9%);
$card-border: rgba($stage-paper, .14);
.signout-card {
width: min(230px, 86vw);
padding: $sp-4;
border-radius: $r-card;
background: $card-bg;
border: 1.5px solid $card-border;
text-align: center;
box-shadow: $shadow-raised;
}
.signout-card__icon { display: inline-flex; width: 26px; height: 26px; margin-bottom: $sp-2; color: $color; }
.signout-card__title { margin: 0 0 $sp-1; font-size: 14px; font-weight: 700; color: $stage-paper; font-family: inherit; }
.signout-card__desc { margin: 0 0 $sp-4; font-size: 11px; line-height: 1.5; color: rgba($stage-paper, .6); font-family: inherit; }
.signout-card__actions { display: flex; gap: $sp-2; justify-content: center; }
.signout-card__btn {
padding: $sp-2 $sp-4;
border-radius: $r-control;
font-size: 12px;
font-weight: 700;
font-family: inherit;
cursor: pointer;
border: 1.5px solid transparent;
}
.signout-card__btn--cancel {
background: transparent;
border-color: rgba($stage-paper, .3);
color: $stage-paper;
}
.signout-card__btn--cancel:hover, .signout-card__btn--cancel:focus-visible { background: rgba($stage-paper, .08); }
.signout-card__btn--danger { background: $color; color: $stage-paper; }
.signout-card__btn--danger:hover, .signout-card__btn--danger:focus-visible { background: darken($color, 12%); }
// 실제 사용 — 확인을 누르면 카드가 위험 상태로 고정된다 (is-demo 없이 열었을 때)
.signout-card.is-confirmed { background: mix($color, $card-bg, 30%); border-color: $color; }
// 자동 재생 — 흔들림(초반) → 카드 전체 배경이 위험 색으로 물들었다 되돌아온다
.signout-card.is-demo { animation: signout-shake $duration $easing infinite, signout-tint $duration $easing infinite; }
@keyframes signout-shake {
0%, 8%, 20%, 100% { transform: translateX(0); }
10% { transform: translateX(-6px); }
12% { transform: translateX(6px); }
15% { transform: translateX(-4px); }
18% { transform: translateX(3px); }
}
@keyframes signout-tint {
0%, 18% { background: $card-bg; border-color: $card-border; }
28%, 74% { background: mix($color, $card-bg, 30%); border-color: $color; }
86%, 100% { background: $card-bg; border-color: $card-border; }
}
@media (prefers-reduced-motion: reduce) {
.signout-card.is-demo { animation: none; }
}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);
}
.signout-card {
width: min(230px, 86vw);
padding: 16px;
border-radius: 16px;
background: rgb(18.0196078431%, 15.6692242114%, 20.3699914749%);
border: 1.5px solid rgba(255, 255, 255, 0.14);
text-align: center;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.signout-card__icon {
display: inline-flex;
width: 26px;
height: 26px;
margin-bottom: 8px;
color: #c2361a;
}
.signout-card__title {
margin: 0 0 4px;
font-size: 14px;
font-weight: 700;
color: #ffffff;
font-family: inherit;
}
.signout-card__desc {
margin: 0 0 16px;
font-size: 11px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.6);
font-family: inherit;
}
.signout-card__actions {
display: flex;
gap: 8px;
justify-content: center;
}
.signout-card__btn {
padding: 8px 16px;
border-radius: 12px;
font-size: 12px;
font-weight: 700;
font-family: inherit;
cursor: pointer;
border: 1.5px solid transparent;
}
.signout-card__btn--cancel {
background: transparent;
border-color: rgba(255, 255, 255, 0.3);
color: #ffffff;
}
.signout-card__btn--cancel:hover, .signout-card__btn--cancel:focus-visible {
background: rgba(255, 255, 255, 0.08);
}
.signout-card__btn--danger {
background: #c2361a;
color: #ffffff;
}
.signout-card__btn--danger:hover, .signout-card__btn--danger:focus-visible {
background: rgb(54.9147950089%, 15.2855614973%, 7.359714795%);
}
.signout-card.is-confirmed {
background: rgb(35.437254902%, 17.3213981245%, 17.3178175618%);
border-color: #c2361a;
}
.signout-card.is-demo {
animation: signout-shake 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite, signout-tint 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes signout-shake {
0%, 8%, 20%, 100% {
transform: translateX(0);
}
10% {
transform: translateX(-6px);
}
12% {
transform: translateX(6px);
}
15% {
transform: translateX(-4px);
}
18% {
transform: translateX(3px);
}
}
@keyframes signout-tint {
0%, 18% {
background: rgb(18.0196078431%, 15.6692242114%, 20.3699914749%);
border-color: rgba(255, 255, 255, 0.14);
}
28%, 74% {
background: rgb(35.437254902%, 17.3213981245%, 17.3178175618%);
border-color: #c2361a;
}
86%, 100% {
background: rgb(18.0196078431%, 15.6692242114%, 20.3699914749%);
border-color: rgba(255, 255, 255, 0.14);
}
}
@media (prefers-reduced-motion: reduce) {
.signout-card.is-demo {
animation: none;
}
}(function(){
var card = document.querySelector('.signout-card'),
cancel = card.querySelector('.signout-card__btn--cancel'),
danger = card.querySelector('.signout-card__btn--danger');
cancel.addEventListener('click', function(){ card.classList.remove('is-confirmed'); });
danger.addEventListener('click', function(){ card.classList.add('is-confirmed'); });
}());import { useState } from "react";
import styles from "./SignOutConfirmCard.module.scss";
export type SignOutConfirmCardProps = {
onCancel?: () => void;
onConfirm?: () => void;
};
/** 사인아웃 전 마지막 확인 카드 — Sign out 을 누르면 위험 상태로 고정되고, Cancel 로 되돌린다 */
export default function SignOutConfirmCard({ onCancel, onConfirm }: SignOutConfirmCardProps) {
const [confirmed, setConfirmed] = useState(false);
const cancel = () => { setConfirmed(false); onCancel?.(); };
const confirm = () => { setConfirmed(true); onConfirm?.(); };
return (
<div className={confirmed ? `${styles.card} ${styles.confirmed}` : styles.card}>
<span className={styles.icon} aria-hidden="true">
<svg viewBox="0 0 256 256">
<g fill="currentColor"><path d="M224 56v144a16 16 0 0 1-16 16H48V40h160a16 16 0 0 1 16 16" opacity=".2" /><path d="M120 216a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V40a8 8 0 0 1 8-8h64a8 8 0 0 1 0 16H56v160h56a8 8 0 0 1 8 8m109.66-93.66l-40-40a8 8 0 0 0-11.32 11.32L204.69 120H112a8 8 0 0 0 0 16h92.69l-26.35 26.34a8 8 0 0 0 11.32 11.32l40-40a8 8 0 0 0 0-11.32" /></g>
</svg>
</span>
<h2 className={styles.title}>Sign out of this device?</h2>
<p className={styles.desc}>You'll need to sign in again to continue.</p>
<div className={styles.actions}>
<button className={styles.cancel} type="button" onClick={cancel}>Cancel</button>
<button className={styles.danger} type="button" onClick={confirm}>Sign out</button>
</div>
</div>
);
}09 · 로그아웃 확인 카드 — 측정 누적면적 28.216% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
실수로 누른 로그아웃을 한 번 더 확인하는 카드나 여러 기기 로그아웃을 경고하는 계정 설정 화면에 씁니다.
(function(){
var card = document.querySelector('.signout-card'),
cancel = card.querySelector('.signout-card__btn--cancel'),
danger = card.querySelector('.signout-card__btn--danger');
cancel.addEventListener('click', function(){ card.classList.remove('is-confirmed'); });
danger.addEventListener('click', function(){ card.classList.add('is-confirmed'); });
}());
어디서 깨지는가 — 자동 포커스와 스크롤
인증번호 입력 그리드(03)를 만들면서 실제로 걸린 함정은 포커스 이동과 스크롤이었습니다. 칸을 채울 때마다 다음 인풋으로 .focus()를 그냥 호출했더니, 브라우저가 기본값으로 그 요소를 화면 안으로 밀어 넣으려 했습니다. 데스크톱 화면에서는 표가 안 났지만 모바일 폭 320px 로 좁혀 인풋이 스크롤 컨테이너 안에 들어가자, 한 자리씩 입력할 때마다 화면이 미세하게 들썩였습니다.
이 들썩임이 데모 하나로 끝나지 않는 이유가 있습니다. 이 사이트는 아홉 개 데모를 홈 목록과 글 안 격자에 iframe 으로 심어 두는데, iframe 안에서 .focus() 가 기본 동작대로 스크롤을 일으키면 그 스크롤이 부모 페이지까지 번집니다. 방문자가 목록을 위로 넘기고 있는데 격자 안 인증번호 칸이 포커스를 받을 때마다 페이지가 아래로 끌려 내려가는 식입니다. 아홉 개 가운데 스크립트에서 .focus() 를 부르는 것은 03 하나뿐이라 고칠 곳은 세 줄이었지만, 자동 이동·백스페이스 되돌아가기·붙여넣기 분배 세 경로가 전부 같은 호출을 지나므로 한 곳만 고치면 나머지 두 경로에서 다시 들썩입니다. { preventScroll: true } 옵션을 넣어 이미 보이는 요소 사이를 옮겨 다닐 때는 스크롤을 막았고, 이 처리는 위 03 코드와 압축 비밀번호 zkv9xjxt로 여는 zip 안 소스 아홉 개 전부에 그대로 들어 있습니다. 인증을 기다리는 동안 아무것도 없는 화면을 어떻게 채우는지는 빈 화면 디자인 9종에서 다뤘습니다. 이후로는 화면 안에서 다음 요소로 넘어가는 모든 .focus() 호출에 같은 옵션을 붙이는 게 이 사이트의 규칙이 됐습니다. 옵션 하나가 어떤 경우에 스크롤을 막는지는 MDN focus() preventScroll 문서에 정리돼 있습니다.
접근성
아홉 개 전부 prefers-reduced-motion: reduce에서 장식용 자동 재생을 끄되 실제 상태 값은 그대로 남깁니다. 권한 모달(01)은 role=alertdialog와 aria-modal로 보조기기에 모달임을 알리고 Esc 로 닫힙니다. 캡차(02)의 체크 배지와 인증번호 그리드(03)의 자동 이동은 reduced-motion 에서 튀는 움직임만 사라지고 체크 상태·inputmode=numeric 같은 실제 정보 전달은 남습니다. 보안 질문(04)은 aria-label로 입력칸의 용도를 알리고, 복구 카드(05)는 role=radiogroup과 라디오 버튼 상태로 지금 선택된 항목을 스크린 리더에 전달하며, 세션 배너(08)는 role=alert로 남은 시간이 줄어드는 것을 중간에 끼어들어서라도 읽어 줍니다.
본인인증 다음 단계로 이어지는 로그인·회원가입 카드도 같은 계정 흐름을 다루고, 이 사이트가 다루는 범위는 소개에 있습니다.
FAQ
otp 인증 구현처럼 실제 서버 인증(OTP 검증·세션 관리)까지 포함하나요?
아니요, 화면 UI 와 애니메이션 패턴만 담았습니다. 인증번호가 맞는지 확인하거나 세션 만료를 판단하는 서버 쪽 로직은 각자의 백엔드에 맞춰 직접 붙여야 합니다.
React 버전도 실제로 상태가 바뀌나요?
네, 상태가 있는 항목은 전부 useState로 들고 있습니다. 인증번호 그리드의 입력 값, 온보딩의 현재 단계, 로그아웃 카드의 확인 여부가 그렇고, 변수 몇 개는 props 로 받아 CSS 사용자 지정 속성으로 넘깁니다.
인증번호 입력 그리드는 실제 전화번호로 문자를 보내나요?
아니요, 칸에 숫자를 채우고 다음 칸으로 넘어가는 동작만 실제로 작동합니다. 문자 발송이나 서버 쪽 인증번호 검증은 포함돼 있지 않고, 화면에 보이는 자동 이동과 붙여넣기 분배 로직만 그대로 코드에 들어 있습니다. 칸 여섯 개의 상태만 아홉 가지로 파고든 편은 인증번호 입력 UI의 9가지 상황입니다.







