本人確認 画面UI 9選 — そのまま使える認証カード
本人確認 画面とは、カメラへのアクセス許可や認証コードの入力、本人確認直後のプロフィール設定まで、ユーザーが本人であることを証明する一連の流れで使われる一時的な画面のことです。ここでは権限リクエストから認証コード入力、ログアウト確認まで、その流れの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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 権限リクエストモーダル
- 02 キャプチャ確認チェックボックス
- 03 認証コード入力グリッド
- 04 秘密の質問 確認カード
- 05 アカウント復旧方法の選択カード
- 06 オンボーディングウィザードのステップカード
- 07 連携アカウント一覧
- 08 セッションタイムアウト警告バナー
- 09 ログアウト確認カード
9つは目立つ順ではなく、実際にユーザーが本人確認を進める順番で並べています。まずアクセス権限を求め、人間かどうかを確認し、認証コード 入力画面へ進み、コードが届かないときは秘密の質問や複数の復旧方法に切り替えます。認証が終わればプロフィール入力・連携アカウント管理・セッション延長・ログアウト確認へと続きます。各デモは画面中央にカード一枚だけを置いているので、認証フロー全体ではなくこの一枚だけを自分のプロジェクトへそのまま移せます。この流れの手前にある最初の関門はログイン画面 デザイン 9選、認証を終えた後の設定画面はアカウント セキュリティ 設定 UI 9選にあります。
01権限リクエストモーダル
2段階認証 画面で何が見えるか
カメラや位置情報へのアクセス許可を求めるとき、アイコンが弾むように動き、「許可」ボタンが色で塗りつぶされて「拒否」より強調されます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
本人確認の前にカメラ・マイクのアクセスを求める画面や、アプリ初回起動時の通知権限案内に向いています。
(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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
認証コード送信前に人間かどうかを確かめる段階や、無料体験申し込みフォームのスパム防止に使われます。
.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つの数字マスが並び、1マスに入力すると自動的に次のマスへ移動し、そのマスの枠が一瞬青く光ります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
SMSで届いた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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
OTPが届かないときの代替認証ステップや、管理者アカウントの二次確認画面に使われます。
.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アカウント復旧方法の選択カード
画面で起きること
メール・SMS・秘密の質問の3つの選択肢がラジオカードとして並び、選ぶとそのカードだけが暗い背景の上で明るく浮き上がります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
パスワードを忘れたときに復旧手段を選ぶ最初の画面や、アカウントロック解除前の確認ステップに向いています。
.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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
会員登録の認証直後に続くプロフィール設定ステップや、初回起動オンボーディングの入力カードに使われます。
(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連携アカウント一覧
何が見えるか
GoogleやGitHubなどのログイン手段が行ごとに並び、接続スイッチを押すとトグルが滑るように切り替わり、行の背景がうっすら色づきます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
設定画面でのソーシャルログイン連携管理や、複数アカウントをまとめる統合ログインダッシュボードに向いています。
.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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
銀行・決済アプリの自動ログアウト前警告や、管理コンソールのアイドルセッション通知に使われます。
(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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
誤って押したログアウトをもう一度確認するカードや、全端末ログアウトを警告するアカウント設定画面に向いています。
(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'); });
}());
どこで壊れるか — 落とし穴
9つを作っている最中に実際に起きたバグが一つありました。認証コード入力グリッド(03)の自動フォーカス移動で、ただ.focus()を呼ぶだけだと、ブラウザは既定でその要素を画面内にスクロールさせようとします。320px幅のモバイル表示のように入力欄がスクロールコンテナの中にある場合、自動移動のたびに画面がわずかに揺れて見えました。
この揺れがデモ一つの問題で終わらないのには理由があります。このサイトは9つのデモをトップページの一覧と記事内のグリッドにiframeとして埋め込んでいるため、iframeの中で.focus()が既定どおりスクロールを起こすと、そのスクロールが親ページまで伝わります。訪問者が一覧を上へ戻している途中でも、グリッド内の認証コードのマスがフォーカスを受けるたびにページが下へ引き戻されるという動きになります。9つのうちスクリプトから.focus()を呼んでいるのは03だけなので直す箇所は3行でしたが、自動移動・Backspaceでの戻り・貼り付け時の6桁振り分けという3つの経路がすべて同じ呼び出しを通るため、1か所だけ直すと残りの2経路でまた揺れが出ます。デスクトップの幅では入力欄が最初から全部見えているので症状が出ず、320px幅に狭めて初めて再現できた点も、気づきにくさの原因でした。デモの計測は480×300と320×200の2つのビューポートで行っているので、狭いほうの計測がなければこの揺れは見逃していたはずです。そこで{ preventScroll: true }オプションを付け、すでに見えている要素同士を移動するときはスクロールを止めるようにしました(上の03のコードに反映済みです)。認証を待つ間、まだ何もない画面をどう埋めるかはエンプティステート デザイン 9選で扱いました。この対処はこの項目だけの話にとどまらず、サイト全体の.focus()呼び出しすべてに適用するルールになりました。ダウンロード用パスワードzkv9xjxtで開くzipの中には、この修正を反映したコード一式が入っています。
アクセシビリティ
9つともprefers-reduced-motion: reduceでは、権限モーダルのアイコンバウンス、キャプチャのばねバッジ、認証コード欄の枠の点滅、秘密の質問カードの冷めるアニメーション、選択カードの浮き上がり、ウィザードカードのスライド、連携アカウントのトグル、タイムアウトバナーの出入り、ログアウトカードの揺れといった装飾用のアニメーションをすべて止めますが、状態そのもの(許可・拒否、確認済み、入力値、選択、カウントダウンの数字など)はそのまま残ります。権限リクエストモーダル(01)はrole="alertdialog"とaria-modalを備え、Escキーで閉じられます。キャプチャ確認(02)はネイティブチェックボックスのchecked状態で確認済みを伝え、認証コード入力(03)はinputmode=numericで数字キーボードを呼び出します。セッションタイムアウトバナー(08)はrole="alert"で残り時間の変化を即座に読み上げ、連携アカウント一覧(07)とログアウト確認(09)はfocus-visibleのリングでキーボード操作の位置を示します。落とし穴で触れた.focus()のスクロール挙動は、MDNのfocus()ドキュメントのpreventScrollオプションの説明にまとまっています。
なお、この9つは画面のUI・アニメーションのパターンであり、実際のサーバー側認証(OTP検証やセッション管理)は行っていません。ログイン・会員登録の画面パターンはログイン・会員登録カードにまとめてあり、このサイト全体が何を扱っているかは紹介ページにあります。
FAQ
認証コード 入力方法を含めて実際のサーバー側認証ロジックまで入っていますか?
いいえ、含まれていません。この9つは画面の見た目と動きだけを再現したUIパターンで、OTPの検証やセッション管理のようなサーバー連携は含まれません。zipの中のコードをもとに、自分のプロジェクトのAPIと接続する部分は別途実装する必要があります。6つの枠だけを9通りの状況まで掘り下げたのが認証コード 入力画面が直面する9つの状況です。
完成した認証フロー画面ではなく、この部品一つだけ使ってもいいですか?
はい、そのために9つに分けて作りました。各デモは認証フロー全体ではなく部品一枚だけを画面中央に置いており、zipの中にもその部品一つ分のHTML・SCSS・JSしか入っていないので、自分のプロジェクトの認証画面へそのまま移せます。
認証コード入力欄(03)の自動移動でスマホの画面が揺れることはありますか?
起きません。開発中は.focus()の既定スクロールが原因で320px幅のモバイル表示で画面がわずかに揺れる問題がありましたが、{ preventScroll: true }オプションを付けて修正済みです。zipに入っているコードにはこの修正がすでに反映されています。







