9 ID Verification Screen UI Patterns, Paste-Ready
An id verification screen ui is a screen — a permission prompt, an OTP grid, a security question card — that proves a person is who they say they are before an
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Permission request modal
- 02 Captcha verify checkbox
- 03 OTP code input grid
- 04 Security question card
- 05 Recovery method picker
- 06 Onboarding wizard step card
- 07 Linked accounts list
- 08 Session timeout warning banner
- 09 Sign-out confirmation card
The order below follows the path a real account actually takes, not a popularity list. It opens with the permission prompt an app shows before it can even check anything, moves through the checks that prove a human is present (captcha, the otp input ui design, a security question), then covers the two ways a person gets back into a locked account (recovery picker, onboarding after verification), and closes with the screens that manage an already-verified session (linked accounts, a timeout warning, and signing out). Each demo keeps a single screen centered on its own page so you can lift out one piece at a time. The gate just before this path is 9 Login Form Design Patterns, and the settings screens that come after verification are 9 account security settings UI patterns.
01Permission request modal
What you see on an identity verification screen
When the app asks for camera or location access, the icon bounces gently in place, and the allow button carries a solid fill next to deny's plain outline so the safer default stands out.

<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 · Permission request modal — Measured area 26.15% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
It's the screen right before an identity check itself, used for camera or mic prompts, or a first-launch notification permission screen.
(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();
});
}());
02Captcha verify checkbox
What happens on screen
Clicking the box spins a brief loader, then swaps to a green check while a verified badge pops in with a springy overshoot.

<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 · Captcha verify checkbox — Measured area 13.285% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
It's the human-check step right before an OTP goes out, and it doubles as spam prevention on a free-trial signup form.
.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); }
}
03OTP code input grid
What moves
Six digit boxes sit in a row; typing in one auto-advances to the next while its border flashes blue for a beat.

<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 · OTP code input grid — Measured area 15.469% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
It's built for a 6-digit SMS code, the same layout that shows up on almost every two-factor authentication screen.
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 });
});
});
04Security question card
What you see
A pre-registered question sits on the card while a red warning glow pulses around it and the answer field, cooling to plain white and a muted gray between pulses.

<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 · Security question card — Measured area 26.226% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
It's the fallback screen for when an OTP can't be delivered, and it also covers a secondary check on an admin account.
.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); }
}
05Recovery method picker
What happens on screen
Email, text, and security-question options sit as radio cards, and picking one lifts just that card into a bright highlight on the dark backdrop.

<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 · Recovery method picker — Measured area 23.378% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
It's the first screen a person sees when choosing a recovery channel after forgetting a password, and it's also a check before unlocking a locked account.
.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; }
06Onboarding wizard step card
What moves
Right after verification, a profile-setup card slides its content to the left when you tap next, and the following pastel-toned card slides in to replace it.

<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 · Onboarding wizard step card — Measured area 20.839% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
It's the profile-setup step that follows signup verification, and the same pattern collects information across a first-run onboarding flow.
(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';
});
}());
07Linked accounts list
What you see
Login providers like Google or GitHub sit in rows, and flipping the connect switch slides the toggle over while the row tints with a soft background color.

<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 · Linked accounts list — Measured area 12.77% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
It's the social-login management screen in account settings, the one dashboard that unifies every sign-in method a person has linked.
.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); }
}
08Session timeout warning banner
What happens on screen
A banner slides down from the top and counts down the remaining seconds, and tapping extend resets the timer back to 30 without dismissing the banner.

<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 · Session timeout warning banner — Measured area 14.106% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
It's the auto-logout warning on a banking or payment app, and the same countdown covers an idle-session alert in an admin console.
(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;
});
}());
09Sign-out confirmation card
What moves
The card shakes and washes red in a recurring warning loop, and hovering the final leave button deepens its background to a darker red.

<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 · Sign-out confirmation card — Measured area 28.216% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
It's a double-check against an accidental sign-out tap, and it's also the screen that warns before signing out every device at once.
(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'); });
}());
Where it breaks — the trap
The real trap while building these nine showed up in item 03, the OTP code grid. Calling a plain .focus() on the auto-advance step makes the browser scroll the newly focused element into view by default, and at a 320px mobile width, where the input sits inside a scroll container, every auto-advance caused a tiny scroll jump even though the next box was already fully visible. The fix was adding { preventScroll: true } to every .focus() call in the grid, as shown in the code above, and MDN's focus() preventScroll documentation confirms this is exactly what the option is for — skipping the browser's default scroll-into-view behavior. It's now a standing rule for this component going forward: even though only the OTP grid calls .focus() among these nine demos, it does it three times, and all three now carry preventScroll: true. Filling a screen that has nothing on it yet, while verification is pending, is covered by 9 Empty State UI Design Cards. The zip behind the download password zkv9xjxt carries every one of these calls already fixed, so pasting the code doesn't reintroduce the jump.
Accessibility (reduced-motion)
All nine turn off their decorative loops under prefers-reduced-motion: reduce — the permission modal's icon bounce, the captcha badge's spring overshoot, the OTP border flash, the security card's glow cycle, the recovery picker's auto-cycle highlight, the wizard's slide transition, the linked-accounts knob demo loop, and the sign-out card's shake — while every real state change still works instantly on click. The permission modal (01) is marked as a modal dialog and closes on Escape, the captcha checkbox (02) and toggle switches (07) expose their checked state to screen readers through the native checkbox itself, the timeout banner (08) announces its countdown through role="alert" so a screen reader hears it without scanning, and the wizard (06) keeps its progress visible with active-state dots on each step, though the step change itself isn't separately announced to screen readers.
These patterns pair well with the login and signup cards a person sees just before reaching any of these verification screens. More on what this site ships is on the about page.
FAQ
Does this 2fa screen ui include real ID verification or OTP logic?
No. These are screen UI and animation patterns only — the OTP grid accepts and formats digits, but nothing here calls a real SMS provider or checks a code against a server. Wiring up actual verification, session, or identity logic is up to your own backend. Digging into the six-box grid alone, across nine states, is OTP Input UI.
Can I use just one of these nine instead of the whole set?
Yes. Each demo centers a single screen on its own page, and the zip ships that one screen's HTML, SCSS, and JS separately, so dropping in just the OTP grid or just the timeout banner works fine on its own.
Does the React version handle the OTP paste and countdown the same way?
Partially. The countdown timer runs through useEffect and useState in the React version and ticks the same way as the vanilla timer, but the OTP paste handler there works through useState and refs directly rather than an effect, and neither component takes duration, easing, or color as props — for those, the vanilla SCSS demo tokens are still the source of truth.







