フォーム ステップ ui 9選 — 分岐と再開まで
フォーム ステップ uiとは、長い入力フォームを複数の画面に分けて、順番に答えを集める作りのことです。9つとも実際の値を読み、実際に検査し、入力のたびに保存します。空欄があるステップは先へ進まず、タブを閉じて開き直しても書きかけの位置から続けられます。
01 wizard-step-lock-gate
02 wizard-auto-height-transition
03 wizard-branch-step-path
04 wizard-resume-draft-banner
05 wizard-live-summary-panel
06 wizard-accordion-vertical-steps
07 wizard-final-review-edit
08 wizard-incomplete-step-return
09 wizard-leave-guard-inline
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 条件が揃うまで開かない次ステップのロック
- 02 ステップごとに高さを合わせるフォームの伸縮
- 03 選んだ答えで分かれるステップの経路
- 04 書きかけの位置を覚えて続きから戻す帯
- 05 打つそばから積み上がる横の要約
- 06 終わったステップが一行に畳まれる縦型ウィザード
- 07 送信前にすべて広げて見る確認画面
- 08 空欄が残ったステップへ送り返す送信
- 09 入力中の値を守るインラインの離脱確認
順番はフォームを作る人の都合ではなく、埋める人がつまずく順です。まず必須の同意が揃わず「次へ」ボタンがロックされたまま(01)、進むとステップごとに項目数が違って箱が跳ねます(02)。個人と事業者で道が分かれる分岐が現れ(03)、途中で離脱した人のために続きから書く帯が待っています(04)。ここまでが道案内です。次の2つは打ちながら確かめる仕事です。入力した値が横に積み上がり(05)、スマホでは終わったステップが一行に畳まれます(06)。最後の3つはフォームが人に筋を通す側です。送信前にすべて広げて見せ(07)、空欄があればそのステップへ送り返し(08)、戻るボタンで値が消える直前に一度だけ尋ねます(09)。フォーム ステップ デザイン で迷うのは見た目よりも、どこでつまずくかを先に決めることのほうです。段階を進む途中の判定はフォーム バリデーション UIが担い、段階ではなく一枚の決済にまとまると決済画面 デザインが引き継ぎます。高さの切り替えは段階が変わる200msのあいだだけ走り、残りは静かな高さに落ち着きます。分岐した経路は戻ってきたとき入力を残し、確定した答えだけが次の段階へ渡ります。戻る操作は確定していない段階なら入力をそのまま残し、確定した段階だけ要約に変わります。進む方向の履歴は分岐した時点で切り捨てて、迷った人が同じ枝を二度歩かないようにしました。
01条件が揃うまで開かない次ステップのロック
何が見えるか
「次へ」ボタンを最初から押せると、反応がない理由が伝わりません。この部品は必須条件の3行がすべてオンになるまでボタンを aria-disabled でロックし、理由の文をボタンのすぐ横に置きます。

<main class="stage">
<section class="lg is-demo" id="lg" aria-label="登録の必須確認ステップ">
<p class="lg__h">ステップ2 · 必須確認</p>
<ul class="lg__list">
<li class="lg__row" data-row="1">
<button class="lg__box" type="button" role="checkbox" aria-checked="false">
<span class="lg__tick" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg></span>
<span class="lg__label">利用規約に同意</span>
</button>
</li>
<li class="lg__row" data-row="2">
<button class="lg__box" type="button" role="checkbox" aria-checked="false">
<span class="lg__tick" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg></span>
<span class="lg__label">個人情報の取得に同意</span>
</button>
</li>
<li class="lg__row" data-row="3">
<button class="lg__box" type="button" role="checkbox" aria-checked="false">
<span class="lg__tick" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg></span>
<span class="lg__label">14歳以上です</span>
</button>
</li>
</ul>
<div class="lg__foot">
<p class="lg__why">
<span class="lg__why-a">必須項目がまだ残っています</span>
<span class="lg__why-b">次のステップへ進めます</span>
</p>
<button class="lg__next" type="button" id="lg-next" aria-disabled="true">
<span class="lg__lock" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" 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 152H48V96h160z"/></g></svg></span>
<span class="lg__next-t">次へ</span>
</button>
</div>
</section>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
$line: color.mix($subject-cream, $stage-ink, 16%);
$soft: color.mix($subject-cream, $stage-ink, 8%);
.lg {
box-sizing: border-box;
width: min(304px, 92vw);
padding: $sp-3;
border: 1px solid $line;
border-radius: $r-card;
background: $stage-ink;
color: $subject-cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.lg__h {
margin: 0 0 $sp-2;
color: rgba(255, 255, 255, .56);
font-size: 11px;
font-weight: 700;
letter-spacing: .04em;
}
.lg__list {
display: grid;
gap: $sp-1;
margin: 0;
padding: 0;
list-style: none;
}
// ── 조건 한 줄 ────────────────────────────────────
.lg__box {
display: flex;
align-items: center;
gap: $sp-2;
width: 100%;
margin: 0;
padding: $sp-1 $sp-2;
border: 1px solid $line;
border-radius: $r-sm;
background: $soft;
color: inherit;
font-family: inherit;
font-size: 12px;
line-height: 1.3;
text-align: left;
cursor: pointer;
}
.lg__box:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.lg__tick {
display: inline-flex;
flex: 0 0 auto;
width: 16px;
height: 16px;
border-radius: $r-xs;
background: rgba(255, 255, 255, .1);
color: transparent;
}
.lg__label { color: rgba(255, 255, 255, .74); }
// 정적(사람이 조작하는) 경로 — .is-demo 를 떼도 결과가 보여야 한다
.lg__row.is-on .lg__tick { background: $color; color: $subject-cream; }
.lg__row.is-on .lg__label { color: $subject-cream; }
// ── 발판 ─────────────────────────────────────────
.lg__foot {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: $sp-2;
margin-top: $sp-2;
}
.lg__why {
position: relative;
margin: 0;
min-height: 16px;
font-size: 11px;
}
.lg__why-a, .lg__why-b { position: absolute; inset: 0; }
.lg__why-a { color: rgba(255, 255, 255, .5); }
.lg__why-b { color: $subject-mint; opacity: 0; }
.lg.is-ready .lg__why-a { opacity: 0; }
.lg.is-ready .lg__why-b { opacity: 1; }
.lg__next {
display: inline-flex;
align-items: center;
gap: $sp-1;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .12);
color: rgba(255, 255, 255, .72);
font-family: inherit;
font-size: 12px;
font-weight: 700;
line-height: 1.6;
cursor: not-allowed;
}
.lg__next:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.lg__lock { display: inline-flex; width: 13px; height: 13px; }
.lg.is-ready .lg__next { background: $color; color: $subject-cream; cursor: pointer; }
.lg.is-ready .lg__lock { display: none; }
.lg.is-sent .lg__next-t { color: $subject-mint; }
.lg.is-nudge .lg__why-a { color: $error-soft; }
// ── 격자 미리보기 전용 2초 루프 ─────────────────────
// 켜짐은 steps(1, end) 로 한 프레임에 자르고(반투명 유령 금지),
// 프레임 수는 같은 줄의 translateY 팝이 채운다
@keyframes lg-on-1 {
0%, 11% { background: rgba(255, 255, 255, .1); color: transparent; }
12%, 100% { background: $color; color: $subject-cream; }
}
@keyframes lg-on-2 {
0%, 31% { background: rgba(255, 255, 255, .1); color: transparent; }
32%, 100% { background: $color; color: $subject-cream; }
}
@keyframes lg-on-3 {
0%, 51% { background: rgba(255, 255, 255, .1); color: transparent; }
52%, 88% { background: $color; color: $subject-cream; }
89%, 100% { background: rgba(255, 255, 255, .1); color: transparent; }
}
@keyframes lg-pop-1 {
0%, 8% { transform: translateY(0); }
14% { transform: translateY(-3px); }
22%, 100% { transform: translateY(0); }
}
@keyframes lg-pop-2 {
0%, 28% { transform: translateY(0); }
34% { transform: translateY(-3px); }
42%, 100% { transform: translateY(0); }
}
@keyframes lg-pop-3 {
0%, 48% { transform: translateY(0); }
54% { transform: translateY(-3px); }
62%, 88% { transform: translateY(0); }
100% { transform: translateY(0); }
}
@keyframes lg-unlock {
0%, 55% { background: rgba(255, 255, 255, .12); color: rgba(255, 255, 255, .72); }
56%, 88% { background: $color; color: $subject-cream; }
89%, 100% { background: rgba(255, 255, 255, .12); color: rgba(255, 255, 255, .72); }
}
@keyframes lg-lock-off {
0%, 55% { transform: scale(1); opacity: 1; }
56%, 88% { transform: scale(.4); opacity: 0; }
89%, 100% { transform: scale(1); opacity: 1; }
}
@keyframes lg-why-a {
0%, 55% { opacity: 1; }
56%, 88% { opacity: 0; }
89%, 100% { opacity: 1; }
}
@keyframes lg-why-b {
0%, 55% { opacity: 0; transform: translateY(3px); }
56% { opacity: 1; transform: translateY(3px); }
64%, 88% { opacity: 1; transform: translateY(0); }
89%, 100% { opacity: 0; transform: translateY(3px); }
}
.lg.is-demo {
.lg__row[data-row="1"] .lg__tick { animation-name: lg-on-1; }
.lg__row[data-row="2"] .lg__tick { animation-name: lg-on-2; }
.lg__row[data-row="3"] .lg__tick { animation-name: lg-on-3; }
.lg__tick {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.lg__row[data-row="1"] .lg__box { animation-name: lg-pop-1; }
.lg__row[data-row="2"] .lg__box { animation-name: lg-pop-2; }
.lg__row[data-row="3"] .lg__box { animation-name: lg-pop-3; }
.lg__box {
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.lg__next {
animation-name: lg-unlock;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.lg__lock {
animation-name: lg-lock-off;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.lg__why-a {
animation-name: lg-why-a;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.lg__why-b {
animation-name: lg-why-b;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.lg { padding: $sp-2; font-size: 11px; }
.lg__h { margin-bottom: $sp-1; }
.lg__box { padding: 0 $sp-2; font-size: 11px; }
.lg__foot { margin-top: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.lg.is-demo .lg__tick,
.lg.is-demo .lg__box,
.lg.is-demo .lg__next,
.lg.is-demo .lg__lock,
.lg.is-demo .lg__why-a,
.lg.is-demo .lg__why-b { animation: none !important; transform: none !important; }
// 움직임만 끄고 판정 결과는 남긴다
.lg.is-demo .lg__tick { background: $color !important; color: $subject-cream !important; }
.lg.is-demo .lg__next { background: $color !important; color: $subject-cream !important; }
.lg.is-demo .lg__lock { display: none !important; }
.lg.is-demo .lg__why-a { opacity: 0 !important; }
.lg.is-demo .lg__why-b { opacity: 1 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lg {
box-sizing: border-box;
width: min(304px, 92vw);
padding: 12px;
border: 1px solid rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
border-radius: 16px;
background: #17141a;
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.lg__h {
margin: 0 0 8px;
color: rgba(255, 255, 255, 0.56);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
}
.lg__list {
display: grid;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.lg__box {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
margin: 0;
padding: 4px 8px;
border: 1px solid rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
border-radius: 8px;
background: rgb(16.2980392157%, 14.9647058824%, 16.5960784314%);
color: inherit;
font-family: inherit;
font-size: 12px;
line-height: 1.3;
text-align: left;
cursor: pointer;
}
.lg__box:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.lg__tick {
display: inline-flex;
flex: 0 0 auto;
width: 16px;
height: 16px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.1);
color: transparent;
}
.lg__label {
color: rgba(255, 255, 255, 0.74);
}
.lg__row.is-on .lg__tick {
background: #2f6df6;
color: #fff7e6;
}
.lg__row.is-on .lg__label {
color: #fff7e6;
}
.lg__foot {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.lg__why {
position: relative;
margin: 0;
min-height: 16px;
font-size: 11px;
}
.lg__why-a, .lg__why-b {
position: absolute;
inset: 0;
}
.lg__why-a {
color: rgba(255, 255, 255, 0.5);
}
.lg__why-b {
color: #4cd4a6;
opacity: 0;
}
.lg.is-ready .lg__why-a {
opacity: 0;
}
.lg.is-ready .lg__why-b {
opacity: 1;
}
.lg__next {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.72);
font-family: inherit;
font-size: 12px;
font-weight: 700;
line-height: 1.6;
cursor: not-allowed;
}
.lg__next:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.lg__lock {
display: inline-flex;
width: 13px;
height: 13px;
}
.lg.is-ready .lg__next {
background: #2f6df6;
color: #fff7e6;
cursor: pointer;
}
.lg.is-ready .lg__lock {
display: none;
}
.lg.is-sent .lg__next-t {
color: #4cd4a6;
}
.lg.is-nudge .lg__why-a {
color: #ffe0d8;
}
@keyframes lg-on-1 {
0%, 11% {
background: rgba(255, 255, 255, 0.1);
color: transparent;
}
12%, 100% {
background: #2f6df6;
color: #fff7e6;
}
}
@keyframes lg-on-2 {
0%, 31% {
background: rgba(255, 255, 255, 0.1);
color: transparent;
}
32%, 100% {
background: #2f6df6;
color: #fff7e6;
}
}
@keyframes lg-on-3 {
0%, 51% {
background: rgba(255, 255, 255, 0.1);
color: transparent;
}
52%, 88% {
background: #2f6df6;
color: #fff7e6;
}
89%, 100% {
background: rgba(255, 255, 255, 0.1);
color: transparent;
}
}
@keyframes lg-pop-1 {
0%, 8% {
transform: translateY(0);
}
14% {
transform: translateY(-3px);
}
22%, 100% {
transform: translateY(0);
}
}
@keyframes lg-pop-2 {
0%, 28% {
transform: translateY(0);
}
34% {
transform: translateY(-3px);
}
42%, 100% {
transform: translateY(0);
}
}
@keyframes lg-pop-3 {
0%, 48% {
transform: translateY(0);
}
54% {
transform: translateY(-3px);
}
62%, 88% {
transform: translateY(0);
}
100% {
transform: translateY(0);
}
}
@keyframes lg-unlock {
0%, 55% {
background: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.72);
}
56%, 88% {
background: #2f6df6;
color: #fff7e6;
}
89%, 100% {
background: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.72);
}
}
@keyframes lg-lock-off {
0%, 55% {
transform: scale(1);
opacity: 1;
}
56%, 88% {
transform: scale(0.4);
opacity: 0;
}
89%, 100% {
transform: scale(1);
opacity: 1;
}
}
@keyframes lg-why-a {
0%, 55% {
opacity: 1;
}
56%, 88% {
opacity: 0;
}
89%, 100% {
opacity: 1;
}
}
@keyframes lg-why-b {
0%, 55% {
opacity: 0;
transform: translateY(3px);
}
56% {
opacity: 1;
transform: translateY(3px);
}
64%, 88% {
opacity: 1;
transform: translateY(0);
}
89%, 100% {
opacity: 0;
transform: translateY(3px);
}
}
.lg.is-demo .lg__row[data-row="1"] .lg__tick {
animation-name: lg-on-1;
}
.lg.is-demo .lg__row[data-row="2"] .lg__tick {
animation-name: lg-on-2;
}
.lg.is-demo .lg__row[data-row="3"] .lg__tick {
animation-name: lg-on-3;
}
.lg.is-demo .lg__tick {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.lg.is-demo .lg__row[data-row="1"] .lg__box {
animation-name: lg-pop-1;
}
.lg.is-demo .lg__row[data-row="2"] .lg__box {
animation-name: lg-pop-2;
}
.lg.is-demo .lg__row[data-row="3"] .lg__box {
animation-name: lg-pop-3;
}
.lg.is-demo .lg__box {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.lg.is-demo .lg__next {
animation-name: lg-unlock;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.lg.is-demo .lg__lock {
animation-name: lg-lock-off;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.lg.is-demo .lg__why-a {
animation-name: lg-why-a;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.lg.is-demo .lg__why-b {
animation-name: lg-why-b;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.lg {
padding: 8px;
font-size: 11px;
}
.lg__h {
margin-bottom: 4px;
}
.lg__box {
padding: 0 8px;
font-size: 11px;
}
.lg__foot {
margin-top: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.lg.is-demo .lg__tick,
.lg.is-demo .lg__box,
.lg.is-demo .lg__next,
.lg.is-demo .lg__lock,
.lg.is-demo .lg__why-a,
.lg.is-demo .lg__why-b {
animation: none !important;
transform: none !important;
}
.lg.is-demo .lg__tick {
background: #2f6df6 !important;
color: #fff7e6 !important;
}
.lg.is-demo .lg__next {
background: #2f6df6 !important;
color: #fff7e6 !important;
}
.lg.is-demo .lg__lock {
display: none !important;
}
.lg.is-demo .lg__why-a {
opacity: 0 !important;
}
.lg.is-demo .lg__why-b {
opacity: 1 !important;
}
}// 실제 동작: 세 조건이 전부 켜져야 次へ 버튼의 aria-disabled 가 false 로 바뀐다.
// 잠긴 동안 눌러도 단계는 안 넘어가고 이유 문구만 그대로 남는다.
var lg = document.getElementById('lg');
var lgBoxes = [].slice.call(lg.querySelectorAll('.lg__box'));
function lgSync() {
var done = lgBoxes.filter(function (b) { return b.getAttribute('aria-checked') === 'true'; }).length;
var ready = done === lgBoxes.length;
lg.classList.toggle('is-ready', ready);
var next = document.getElementById('lg-next');
next.setAttribute('aria-disabled', ready ? 'false' : 'true');
}
lgBoxes.forEach(function (b) {
b.addEventListener('click', function () {
lg.classList.remove('is-demo');
b.setAttribute('aria-checked', b.getAttribute('aria-checked') === 'true' ? 'false' : 'true');
b.parentNode.classList.toggle('is-on', b.getAttribute('aria-checked') === 'true');
lgSync();
});
});
document.getElementById('lg-next').addEventListener('click', function () {
lg.classList.remove('is-demo');
if (this.getAttribute('aria-disabled') === 'true') { lg.classList.add('is-nudge'); return; }
lg.classList.remove('is-nudge');
lg.classList.add('is-sent');
});
lgSync();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StepLockGate.module.scss";
export type StepLockGateProps = {
heading?: string;
rules?: string[];
lockedHint?: string;
readyHint?: string;
nextLabel?: string;
groupLabel?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const DEFAULT_RULES = ["Accept the terms of service", "Accept the privacy notice", "I am 14 or older"];
function Tick() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2" />
<path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32" />
</g>
</svg>
);
}
function Lock() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M216 96v112a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8h160a8 8 0 0 1 8 8" 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 152H48V96h160z" />
</g>
</svg>
);
}
export default function StepLockGate({
heading = "Step 2 · Required checks",
rules = DEFAULT_RULES,
lockedHint = "Required items are still open",
readyHint = "You can move to the next step",
nextLabel = "Next",
groupLabel = "Required checks for signup",
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: StepLockGateProps) {
const [on, setOn] = useState<boolean[]>(rules.map(() => false));
const [live, setLive] = useState(demo);
const [nudge, setNudge] = useState(false);
const ready = on.length > 0 && on.every(Boolean);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function toggle(i: number) {
setLive(false);
setOn((prev) => prev.map((v, n) => (n === i ? !v : v)));
}
function next() {
setLive(false);
// 잠긴 동안 눌러도 단계는 안 넘어간다 — 이유만 강조한다
setNudge(!ready);
}
const cls = [styles.lg, live ? styles.isDemo : "", ready ? styles.isReady : "", nudge ? styles.isNudge : ""]
.filter(Boolean)
.join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<p className={styles.h}>{heading}</p>
<ul className={styles.list}>
{rules.map((r, i) => (
<li key={r} className={[styles.row, on[i] ? styles.isOn : ""].filter(Boolean).join(" ")} data-row={i + 1}>
<button className={styles.box} type="button" role="checkbox" aria-checked={on[i]} onClick={() => toggle(i)}>
<span className={styles.tick} aria-hidden="true"><Tick /></span>
<span className={styles.label}>{r}</span>
</button>
</li>
))}
</ul>
<div className={styles.foot}>
<p className={styles.why}>
<span className={styles.whyA}>{lockedHint}</span>
<span className={styles.whyB}>{readyHint}</span>
</p>
<button className={styles.next} type="button" aria-disabled={!ready} onClick={next}>
{!ready && <span className={styles.lock} aria-hidden="true"><Lock /></span>}
<span className={styles.nextT}>{nextLabel}</span>
</button>
</div>
</section>
);
}01 · 条件が揃うまで開かない次ステップのロック — 計測 累積面積 8.435% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
最後の条件が埋まった瞬間、ロックが1フレームで外れ、理由の文が緑の「次のステップへ進めます」に変わります。
function lgSync() {
var done = lgBoxes.filter(function (b) {
return b.getAttribute('aria-checked') === 'true';
}).length;
var ready = done === lgBoxes.length;
lg.classList.toggle('is-ready', ready);
var next = document.getElementById('lg-next');
next.setAttribute('aria-disabled', ready ? 'false' : 'true');
}
02ステップごとに高さを合わせるフォームの伸縮
フォーム ステップ 表示 の高さは画面でどう変わるか
ステップ1は項目が2つ、ステップ2は3つ、ステップ3は1つしかない、となると箱の高さが境目ごとに跳ねます。この部品は次のパネルの実際の高さを getBoundingClientRect() で測って、長さ型で登録したカスタムプロパティ --h にまとめ、箱がその値まで流れます。

<main class="stage">
<section class="ah is-demo" id="ah" aria-label="項目数が違うステップをつなぐ申込フォーム">
<div class="ah__bar" id="ah-bar" role="progressbar" aria-valuemin="1" aria-valuemax="3" aria-valuenow="1" aria-label="ステップの進捗">
<span class="ah__seg" data-seg="1"><span class="ah__seg-f"></span></span>
<span class="ah__seg" data-seg="2"><span class="ah__seg-f"></span></span>
<span class="ah__seg" data-seg="3"><span class="ah__seg-f"></span></span>
</div>
<div class="ah__box" id="ah-box">
<div class="ah__panel is-on" data-step="1">
<p class="ah__t">1 / 3 · アカウント</p>
<input class="ah__f" type="text" placeholder="メール">
<input class="ah__f" type="text" placeholder="パスワード">
</div>
<div class="ah__panel" data-step="2">
<p class="ah__t">2 / 3 · お届け先</p>
<input class="ah__f" type="text" placeholder="宛名">
<input class="ah__f" type="text" placeholder="住所">
<input class="ah__f" type="text" placeholder="電話番号">
</div>
<div class="ah__panel" data-step="3">
<p class="ah__t">3 / 3 · 確認</p>
<input class="ah__f" type="text" placeholder="クーポン番号">
<p class="ah__note">送信前に前のステップで入力した値をご確認ください。直す所があれば戻ります。</p>
</div>
</div>
<div class="ah__foot">
<button class="ah__btn" type="button" id="ah-prev">戻る</button>
<button class="ah__btn ah__btn--go" type="button" id="ah-next">次へ</button>
</div>
</section>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-yellow);
$line: color.mix($subject-ink, $stage-yellow, 22%);
$soft: color.mix($subject-ink, $stage-yellow, 8%);
// 단계마다 다른 상자 높이. 길이형으로 등록해야 보간된다 —
// 등록하지 않은 커스텀 속성은 중간값이 없어 높이가 툭 튄다
@property --h {
syntax: "<length>";
inherits: true;
initial-value: 63px;
}
// 패널 셋의 실측 높이(측정값과 맞춰 둔 루프용 값)
$h1: 63px;
$h2: 87px;
$h3: 70px;
.ah {
box-sizing: border-box;
width: min(304px, 92vw);
padding: $sp-3;
border: 1px solid $line;
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
// ── 진행 막대 ─────────────────────────────────────
.ah__bar {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $sp-1;
margin-bottom: $sp-2;
}
.ah__seg {
height: 4px;
border-radius: $r-pill;
background: $soft;
overflow: hidden;
}
.ah__seg-f {
display: block;
height: 100%;
border-radius: inherit;
background: $color;
transform: scaleX(0);
transform-origin: left center; // 규칙8: 단계 막대 좌측에서 채움
transition: transform $duration $easing;
}
// 정적(사람이 조작하는) 경로 — .is-demo 를 떼도 진행 상태가 보여야 한다
.ah__seg.is-done .ah__seg-f { transform: scaleX(1); }
// ── 높이가 흐르는 상자 ─────────────────────────────
.ah__box {
--h: #{$h1};
display: grid;
align-items: start;
height: var(--h);
overflow: hidden;
transition: --h $duration $easing;
}
.ah__panel {
display: grid;
grid-area: 1 / 1;
align-content: start;
gap: $sp-1;
opacity: 0;
visibility: hidden;
}
.ah__panel.is-on { opacity: 1; visibility: visible; }
.ah__t {
margin: 0;
color: rgba($subject-ink, .72);
font-size: 11px;
font-weight: 700;
letter-spacing: .04em;
}
.ah__f {
box-sizing: border-box;
width: 100%;
height: 20px;
margin: 0;
padding: 0 $sp-2;
border: 1px solid $line;
border-radius: $r-sm;
background: $stage-paper;
color: $subject-ink;
font-family: inherit;
font-size: 11px;
line-height: 1.3;
}
.ah__f:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.ah__note {
margin: 0;
color: color.mix($subject-ink, $subject-cream, 62%);
font-size: 11px;
line-height: 1.25;
}
// ── 발판 ─────────────────────────────────────────
.ah__foot {
display: grid;
grid-template-columns: auto auto;
justify-content: space-between;
gap: $sp-2;
margin-top: $sp-2;
}
.ah__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 1px solid $line;
border-radius: $r-pill;
background: transparent;
color: color.mix($subject-ink, $subject-cream, 70%);
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
cursor: pointer;
}
.ah__btn--go { border-color: $subject-blue-deep; background: $subject-blue-deep; color: $subject-cream; }
.ah__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// ── 격자 미리보기 전용 2초 루프 ─────────────────────
// --h 는 등록돼 있으니 키프레임 사이가 보간된다(상자가 흐른다).
// 패널 교체만 steps(1, end) 로 한 프레임에 자른다 — 반투명 유령 금지
@keyframes ah-h {
0%, 18% { --h: #{$h1}; }
30%, 46% { --h: #{$h2}; }
58%, 72% { --h: #{$h3}; }
86%, 100% { --h: #{$h1}; }
}
@keyframes ah-p1 {
0%, 17% { opacity: 1; visibility: visible; }
18%, 71% { opacity: 0; visibility: hidden; }
72%, 100% { opacity: 1; visibility: visible; }
}
@keyframes ah-p2 {
0%, 17% { opacity: 0; visibility: hidden; }
18%, 45% { opacity: 1; visibility: visible; }
46%, 100% { opacity: 0; visibility: hidden; }
}
@keyframes ah-p3 {
0%, 45% { opacity: 0; visibility: hidden; }
46%, 71% { opacity: 1; visibility: visible; }
72%, 100% { opacity: 0; visibility: hidden; }
}
@keyframes ah-seg2 {
0%, 17% { transform: scaleX(0); }
18% { transform: scaleX(0); }
30%, 71% { transform: scaleX(1); }
84%, 100% { transform: scaleX(0); }
}
@keyframes ah-seg3 {
0%, 45% { transform: scaleX(0); }
46% { transform: scaleX(0); }
58%, 71% { transform: scaleX(1); }
84%, 100% { transform: scaleX(0); }
}
.ah.is-demo {
// 루프 중에는 전환을 끈다 — 전환이 살아 있으면 캐스케이드에서 애니메이션을 덮는다
.ah__box, .ah__seg-f { transition: none; }
.ah__box {
animation-name: ah-h;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ah__panel[data-step="1"] { animation-name: ah-p1; }
.ah__panel[data-step="2"] { animation-name: ah-p2; }
.ah__panel[data-step="3"] { animation-name: ah-p3; }
.ah__panel {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ah__seg[data-seg="2"] .ah__seg-f { animation-name: ah-seg2; }
.ah__seg[data-seg="3"] .ah__seg-f { animation-name: ah-seg3; }
.ah__seg[data-seg="2"] .ah__seg-f,
.ah__seg[data-seg="3"] .ah__seg-f {
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.ah { padding: $sp-2; font-size: 11px; }
.ah__btn { padding: 0 $sp-2; }
.ah__note { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.ah.is-demo .ah__box,
.ah.is-demo .ah__panel,
.ah.is-demo .ah__seg-f { animation: none !important; transition: none !important; }
// 움직임만 끄고 진행 상태는 남긴다 — 칸 셋짜리 2단계에 맞춰진 상자
.ah.is-demo .ah__box { --h: #{$h2} !important; }
.ah.is-demo .ah__panel[data-step="1"],
.ah.is-demo .ah__panel[data-step="3"] { opacity: 0 !important; visibility: hidden !important; }
.ah.is-demo .ah__panel[data-step="2"] { opacity: 1 !important; visibility: visible !important; }
.ah.is-demo .ah__seg[data-seg="1"] .ah__seg-f,
.ah.is-demo .ah__seg[data-seg="2"] .ah__seg-f { transform: scaleX(1) !important; }
.ah.is-demo .ah__seg[data-seg="3"] .ah__seg-f { transform: scaleX(0) !important; }
}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);
}
@property --h {
syntax: "<length>";
inherits: true;
initial-value: 63px;
}
.ah {
box-sizing: border-box;
width: min(304px, 92vw);
padding: 12px;
border: 1px solid rgb(79.9843137255%, 65.9607843137%, 21.5137254902%);
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.ah__bar {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
margin-bottom: 8px;
}
.ah__seg {
height: 4px;
border-radius: 999px;
background: rgb(92.7215686275%, 76.3921568627%, 23.5450980392%);
overflow: hidden;
}
.ah__seg-f {
display: block;
height: 100%;
border-radius: inherit;
background: #2f6df6;
transform: scaleX(0);
transform-origin: left center;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ah__seg.is-done .ah__seg-f {
transform: scaleX(1);
}
.ah__box {
--h: 63px;
display: grid;
align-items: start;
height: var(--h);
overflow: hidden;
transition: --h 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ah__panel {
display: grid;
grid-area: 1/1;
align-content: start;
gap: 4px;
opacity: 0;
visibility: hidden;
}
.ah__panel.is-on {
opacity: 1;
visibility: visible;
}
.ah__t {
margin: 0;
color: rgba(23, 20, 26, 0.72);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
}
.ah__f {
box-sizing: border-box;
width: 100%;
height: 20px;
margin: 0;
padding: 0 8px;
border: 1px solid rgb(79.9843137255%, 65.9607843137%, 21.5137254902%);
border-radius: 8px;
background: #ffffff;
color: #17141a;
font-family: inherit;
font-size: 11px;
line-height: 1.3;
}
.ah__f:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.ah__note {
margin: 0;
color: rgb(43.5921568627%, 41.6705882353%, 40.5960784314%);
font-size: 11px;
line-height: 1.25;
}
.ah__foot {
display: grid;
grid-template-columns: auto auto;
justify-content: space-between;
gap: 8px;
margin-top: 8px;
}
.ah__btn {
margin: 0;
padding: 4px 12px;
border: 1px solid rgb(79.9843137255%, 65.9607843137%, 21.5137254902%);
border-radius: 999px;
background: transparent;
color: rgb(36.3137254902%, 34.5490196078%, 34.1960784314%);
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
cursor: pointer;
}
.ah__btn--go {
border-color: #0a4fe8;
background: #0a4fe8;
color: #fff7e6;
}
.ah__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
@keyframes ah-h {
0%, 18% {
--h: 63px;
}
30%, 46% {
--h: 87px;
}
58%, 72% {
--h: 70px;
}
86%, 100% {
--h: 63px;
}
}
@keyframes ah-p1 {
0%, 17% {
opacity: 1;
visibility: visible;
}
18%, 71% {
opacity: 0;
visibility: hidden;
}
72%, 100% {
opacity: 1;
visibility: visible;
}
}
@keyframes ah-p2 {
0%, 17% {
opacity: 0;
visibility: hidden;
}
18%, 45% {
opacity: 1;
visibility: visible;
}
46%, 100% {
opacity: 0;
visibility: hidden;
}
}
@keyframes ah-p3 {
0%, 45% {
opacity: 0;
visibility: hidden;
}
46%, 71% {
opacity: 1;
visibility: visible;
}
72%, 100% {
opacity: 0;
visibility: hidden;
}
}
@keyframes ah-seg2 {
0%, 17% {
transform: scaleX(0);
}
18% {
transform: scaleX(0);
}
30%, 71% {
transform: scaleX(1);
}
84%, 100% {
transform: scaleX(0);
}
}
@keyframes ah-seg3 {
0%, 45% {
transform: scaleX(0);
}
46% {
transform: scaleX(0);
}
58%, 71% {
transform: scaleX(1);
}
84%, 100% {
transform: scaleX(0);
}
}
.ah.is-demo .ah__box, .ah.is-demo .ah__seg-f {
transition: none;
}
.ah.is-demo .ah__box {
animation-name: ah-h;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.ah.is-demo .ah__panel[data-step="1"] {
animation-name: ah-p1;
}
.ah.is-demo .ah__panel[data-step="2"] {
animation-name: ah-p2;
}
.ah.is-demo .ah__panel[data-step="3"] {
animation-name: ah-p3;
}
.ah.is-demo .ah__panel {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ah.is-demo .ah__seg[data-seg="2"] .ah__seg-f {
animation-name: ah-seg2;
}
.ah.is-demo .ah__seg[data-seg="3"] .ah__seg-f {
animation-name: ah-seg3;
}
.ah.is-demo .ah__seg[data-seg="2"] .ah__seg-f,
.ah.is-demo .ah__seg[data-seg="3"] .ah__seg-f {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.ah {
padding: 8px;
font-size: 11px;
}
.ah__btn {
padding: 0 8px;
}
.ah__note {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.ah.is-demo .ah__box,
.ah.is-demo .ah__panel,
.ah.is-demo .ah__seg-f {
animation: none !important;
transition: none !important;
}
.ah.is-demo .ah__box {
--h: 87px !important;
}
.ah.is-demo .ah__panel[data-step="1"],
.ah.is-demo .ah__panel[data-step="3"] {
opacity: 0 !important;
visibility: hidden !important;
}
.ah.is-demo .ah__panel[data-step="2"] {
opacity: 1 !important;
visibility: visible !important;
}
.ah.is-demo .ah__seg[data-seg="1"] .ah__seg-f,
.ah.is-demo .ah__seg[data-seg="2"] .ah__seg-f {
transform: scaleX(1) !important;
}
.ah.is-demo .ah__seg[data-seg="3"] .ah__seg-f {
transform: scaleX(0) !important;
}
}// 실제 동작: 단계를 바꿀 때마다 그 패널의 실제 높이를 재서 --h 에 넣는다.
// --h 는 길이형으로 등록돼 있어 상자가 그 값까지 흐른다(짐작한 고정 높이가 아니다).
var ah = document.getElementById('ah');
var ahBox = document.getElementById('ah-box');
var ahBar = document.getElementById('ah-bar');
var ahPanels = [].slice.call(ah.querySelectorAll('.ah__panel'));
var ahSegs = [].slice.call(ah.querySelectorAll('.ah__seg'));
var ahStep = 1;
function ahGo(n) {
ahStep = Math.max(1, Math.min(ahPanels.length, n));
ahPanels.forEach(function (p, i) { p.classList.toggle('is-on', i + 1 === ahStep); });
ahSegs.forEach(function (s, i) { s.classList.toggle('is-done', i + 1 <= ahStep); });
var h = Math.ceil(ahPanels[ahStep - 1].getBoundingClientRect().height);
ahBox.style.setProperty('--h', h + 'px');
ahBar.setAttribute('aria-valuenow', String(ahStep));
}
function ahMove(d) { ah.classList.remove('is-demo'); ahGo(ahStep + d); }
document.getElementById('ah-next').addEventListener('click', function () { ahMove(1); });
document.getElementById('ah-prev').addEventListener('click', function () { ahMove(-1); });
ah.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight') { e.preventDefault(); ahMove(1); }
if (e.key === 'ArrowLeft') { e.preventDefault(); ahMove(-1); }
});
ahGo(1);import { useCallback, useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./AutoHeightTransition.module.scss";
export type AutoHeightStep = {
/** "1 / 3 · Account" */
title: string;
/** placeholder of each field on this step */
fields: string[];
note?: string;
};
export type AutoHeightTransitionProps = {
steps?: AutoHeightStep[];
prevLabel?: string;
nextLabel?: string;
groupLabel?: string;
progressLabel?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type KeyEvt = { key: string; preventDefault(): void };
type Box<T> = { current: T };
const DEFAULT_STEPS: AutoHeightStep[] = [
{ title: "1 / 3 · Account", fields: ["Email", "Password"] },
{ title: "2 / 3 · Delivery", fields: ["Recipient", "Address", "Phone"] },
{
title: "3 / 3 · Review",
fields: ["Coupon code"],
note: "Check what you typed on earlier steps before sending. Go back if something needs a fix.",
},
];
export default function AutoHeightTransition({
steps = DEFAULT_STEPS,
prevLabel = "Back",
nextLabel = "Next",
groupLabel = "Application form with uneven field counts",
progressLabel = "Step progress",
demo = true,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: AutoHeightTransitionProps) {
const [step, setStep] = useState(1);
const [live, setLive] = useState(demo);
const [h, setH] = useState<number | null>(null);
const panels = useRef<HTMLDivElement[]>([]) as Box<HTMLDivElement[]>;
// 다음 패널의 실제 높이를 재서 --h 에 넣는다 — 짐작한 고정 높이가 아니다
const measure = useCallback((n: number) => {
const el = panels.current[n - 1];
if (el) setH(Math.ceil(el.getBoundingClientRect().height));
}, []);
useEffect(() => {
measure(step);
}, [measure, step]);
function move(d: number) {
setLive(false);
setStep((p) => Math.max(1, Math.min(steps.length, p + d)));
}
function onKey(e: KeyEvt) {
if (e.key === "ArrowRight") {
e.preventDefault();
move(1);
}
if (e.key === "ArrowLeft") {
e.preventDefault();
move(-1);
}
}
const vars: CSSProperties = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
const boxVars: CSSProperties = h === null ? {} : { "--h": `${h}px` };
const cls = [styles.ah, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel} onKeyDown={onKey}>
<div
className={styles.bar}
role="progressbar"
aria-valuemin={1}
aria-valuemax={steps.length}
aria-valuenow={step}
aria-label={progressLabel}
>
{steps.map((s, i) => (
<span
key={s.title}
className={[styles.seg, i + 1 <= step ? styles.isDone : ""].filter(Boolean).join(" ")}
data-seg={i + 1}
>
<span className={styles.segF} />
</span>
))}
</div>
<div className={styles.box} style={boxVars}>
{steps.map((s, i) => (
<div
key={s.title}
className={[styles.panel, i + 1 === step ? styles.isOn : ""].filter(Boolean).join(" ")}
data-step={i + 1}
ref={(el: HTMLDivElement | null) => {
if (el) panels.current[i] = el;
}}
>
<p className={styles.t}>{s.title}</p>
{s.fields.map((f) => (
<input key={f} className={styles.f} type="text" placeholder={f} />
))}
{s.note ? <p className={styles.note}>{s.note}</p> : null}
</div>
))}
</div>
<div className={styles.foot}>
<button className={styles.btn} type="button" onClick={() => move(-1)}>
{prevLabel}
</button>
<button className={[styles.btn, styles.btnGo].join(" ")} type="button" onClick={() => move(1)}>
{nextLabel}
</button>
</div>
</section>
);
}02 · ステップごとに高さを合わせるフォームの伸縮 — 計測 累積面積 22.462% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
--h が @property で登録されているおかげで中間値が生まれる点が核心です。
@property --h {
syntax: "<length>";
inherits: true;
initial-value: 63px;
}
.ah__box {
--h: 63px;
display: grid;
align-items: start;
height: var(--h);
overflow: hidden;
transition: --h 300ms cubic-bezier(.2, .8, .2, 1);
}
03選んだ答えで分かれるステップの経路
どんな動きか
個人アカウントと事業者アカウントでは、必要なステップ数からして違います。ラジオを選んだ瞬間、ステップ配列をもう一度しぼって一覧を作り直します。事業者を選ぶと「事業者情報」の枠が割って入り、3枠が4枠になり、均等に割る枠数も配列の長さから書き直されます。

<main class="stage">
<section class="bp is-demo" id="bp" aria-label="選んだ答えで分かれる登録ステップ">
<div class="bp__pick" role="radiogroup" aria-label="登録の種別">
<label class="bp__opt">
<input class="bp__r" type="radio" name="bp-kind" value="personal">
<span class="bp__opt-b" data-opt="personal">
<span class="bp__opt-i" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2"/><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56"/></g></svg></span>
<span class="bp__opt-t">個人</span>
</span>
</label>
<label class="bp__opt">
<input class="bp__r" type="radio" name="bp-kind" value="business" checked>
<span class="bp__opt-b" data-opt="business">
<span class="bp__opt-i" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M224 118.31V200a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-81.69A191.14 191.14 0 0 0 128 144a191.1 191.1 0 0 0 96-25.69" opacity=".2"/><path d="M104 112a8 8 0 0 1 8-8h32a8 8 0 0 1 0 16h-32a8 8 0 0 1-8-8m128-40v128a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V72a16 16 0 0 1 16-16h40v-8a24 24 0 0 1 24-24h48a24 24 0 0 1 24 24v8h40a16 16 0 0 1 16 16M96 56h64v-8a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8ZM40 72v41.62A184.1 184.1 0 0 0 128 136a184 184 0 0 0 88-22.39V72Zm176 128v-68.37A200.25 200.25 0 0 1 128 152a200.2 200.2 0 0 1-88-20.36V200z"/></g></svg></span>
<span class="bp__opt-t">事業者</span>
</span>
</label>
</div>
<ol class="bp__steps" id="bp-steps"></ol>
<p class="bp__note">選んだ種別に合うステップだけが残ります</p>
</section>
<template id="bp-src">
<li data-k="all">本人確認</li>
<li data-k="all">アカウント</li>
<li data-k="biz">事業者情報</li>
<li data-k="all">完了</li>
</template>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-orange);
$card: color.mix($subject-ink, $stage-orange, 8%);
$line: color.mix($subject-cream, $card, 26%);
$soft: color.mix($subject-cream, $card, 14%);
$mute: color.mix($subject-cream, $card, 66%);
.bp {
box-sizing: border-box;
width: min(312px, 92vw);
padding: $sp-3;
border: 1px solid $line;
border-radius: $r-card;
background: $card;
color: $color;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
// ── 갈림길을 고르는 라디오 ──────────────────────────
.bp__pick {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $sp-2;
}
.bp__opt {
position: relative;
display: block;
cursor: pointer;
}
// 라디오 자체는 감추되 초점은 그대로 받는다 — 화살표 키 이동이 네이티브로 돈다
.bp__r {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
opacity: 0;
}
.bp__opt-b {
display: flex;
align-items: center;
justify-content: center;
gap: $sp-1;
height: 26px;
border-radius: $r-pill;
background: $soft;
color: $subject-ink;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
}
.bp__opt-i { display: inline-flex; width: 13px; height: 13px; }
// 정적(사람이 고르는) 경로 — .is-demo 를 떼도 고른 값이 보여야 한다
.bp__r:checked + .bp__opt-b { background: $color; color: $subject-orange-deep; }
.bp__r:focus-visible + .bp__opt-b { outline: 2px solid $color; outline-offset: 2px; }
// ── 다시 그려지는 단계 목록 ─────────────────────────
// 번호는 CSS counter 가 센다 — 칸이 하나 빠지면 뒤 칸 번호가 저절로 당겨진다
.bp__steps {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $sp-1;
counter-reset: bps;
margin: $sp-2 0 0;
padding: 0;
list-style: none;
}
.bp__s {
display: grid;
align-content: start;
justify-items: center;
gap: $sp-1;
counter-increment: bps;
padding: $sp-1 0 0;
border-top: 3px solid $line;
}
.bp__s::before {
content: counter(bps);
display: grid;
place-items: center;
width: 18px;
height: 18px;
border-radius: 50%;
background: $soft;
color: $subject-ink;
font-size: 10px;
font-weight: 700;
line-height: 1;
}
.bp__l {
color: $subject-ink;
font-size: 10px;
line-height: 1.2;
text-align: center;
}
.bp__s[aria-current="step"] { border-top-color: $color; }
.bp__s[aria-current="step"]::before { background: $color; color: $subject-orange-deep; }
.bp__s[aria-current="step"] .bp__l { color: $subject-ink; font-weight: 700; }
.bp__note {
margin: $sp-2 0 0;
color: $subject-ink;
font-size: 10px;
line-height: 1.3;
}
// ── 격자 미리보기 전용 2초 루프 ─────────────────────
// 고른 값과 칸 수는 steps(1, end) 로 한 프레임에 컷,
// 새로 끼어드는 칸만 translateX·scaleX 로 보조 움직임을 준다
@keyframes bp-cols {
0%, 29% { grid-template-columns: repeat(3, 1fr); }
30%, 69% { grid-template-columns: repeat(4, 1fr); }
70%, 100% { grid-template-columns: repeat(3, 1fr); }
}
@keyframes bp-biz {
0%, 29% { display: none; }
30%, 69% { display: grid; }
70%, 100% { display: none; }
}
@keyframes bp-biz-in {
0%, 30% { transform: translateX(-12px) scaleX(.72); }
44%, 69% { transform: translateX(0) scaleX(1); }
70%, 100% { transform: translateX(-12px) scaleX(.72); }
}
@keyframes bp-pick-p {
0%, 29% { background: $color; color: $subject-orange-deep; }
30%, 69% { background: $soft; color: $subject-ink; }
70%, 100% { background: $color; color: $subject-orange-deep; }
}
@keyframes bp-pick-b {
0%, 29% { background: $soft; color: $subject-ink; }
30%, 69% { background: $color; color: $subject-orange-deep; }
70%, 100% { background: $soft; color: $subject-ink; }
}
@keyframes bp-pop-b {
0%, 30% { transform: scale(1); }
37% { transform: scale(1.07); }
47%, 100% { transform: scale(1); }
}
@keyframes bp-pop-p {
0%, 70% { transform: scale(1); }
77% { transform: scale(1.07); }
87%, 100% { transform: scale(1); }
}
.bp.is-demo {
.bp__steps {
animation-name: bp-cols;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.bp__s--biz {
animation-name: bp-biz, bp-biz-in;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
}
.bp__opt-b[data-opt="personal"] {
animation-name: bp-pick-p, bp-pop-p;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
}
.bp__opt-b[data-opt="business"] {
animation-name: bp-pick-b, bp-pop-b;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
}
}
@media (max-width: 340px) {
.bp { padding: $sp-2; font-size: 11px; }
.bp__pick { gap: $sp-1; }
.bp__opt-b { gap: 0; font-size: 10px; }
.bp__note { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
.bp.is-demo .bp__steps,
.bp.is-demo .bp__s--biz,
.bp.is-demo .bp__opt-b { animation: none !important; transform: none !important; }
// 움직임만 끄고 고른 값과 그에 맞는 칸 수는 남긴다 — 사업자(네 칸)
.bp.is-demo .bp__steps { grid-template-columns: repeat(4, 1fr) !important; }
.bp.is-demo .bp__s--biz { display: grid !important; }
.bp.is-demo .bp__opt-b[data-opt="business"] { background: $color !important; color: $subject-orange-deep !important; }
.bp.is-demo .bp__opt-b[data-opt="personal"] { background: $soft !important; color: $subject-ink !important; }
}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);
}
.bp {
box-sizing: border-box;
width: min(312px, 92vw);
padding: 12px;
border: 1px solid rgb(94.6139607843%, 46.2061176471%, 32.3309803922%);
border-radius: 16px;
background: rgb(92.7215686275%, 28.4078431373%, 12%);
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.bp__pick {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.bp__opt {
position: relative;
display: block;
cursor: pointer;
}
.bp__r {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
opacity: 0;
}
.bp__opt-b {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 26px;
border-radius: 999px;
background: rgb(93.7405490196%, 37.9915294118%, 22.9474509804%);
color: #17141a;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
}
.bp__opt-i {
display: inline-flex;
width: 13px;
height: 13px;
}
.bp__r:checked + .bp__opt-b {
background: #fff7e6;
color: #c93000;
}
.bp__r:focus-visible + .bp__opt-b {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.bp__steps {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
counter-reset: bps;
margin: 8px 0 0;
padding: 0;
list-style: none;
}
.bp__s {
display: grid;
align-content: start;
justify-items: center;
gap: 4px;
counter-increment: bps;
padding: 4px 0 0;
border-top: 3px solid rgb(94.6139607843%, 46.2061176471%, 32.3309803922%);
}
.bp__s::before {
content: counter(bps);
display: grid;
place-items: center;
width: 18px;
height: 18px;
border-radius: 50%;
background: rgb(93.7405490196%, 37.9915294118%, 22.9474509804%);
color: #17141a;
font-size: 10px;
font-weight: 700;
line-height: 1;
}
.bp__l {
color: #17141a;
font-size: 10px;
line-height: 1.2;
text-align: center;
}
.bp__s[aria-current=step] {
border-top-color: #fff7e6;
}
.bp__s[aria-current=step]::before {
background: #fff7e6;
color: #c93000;
}
.bp__s[aria-current=step] .bp__l {
color: #17141a;
font-weight: 700;
}
.bp__note {
margin: 8px 0 0;
color: #17141a;
font-size: 10px;
line-height: 1.3;
}
@keyframes bp-cols {
0%, 29% {
grid-template-columns: repeat(3, 1fr);
}
30%, 69% {
grid-template-columns: repeat(4, 1fr);
}
70%, 100% {
grid-template-columns: repeat(3, 1fr);
}
}
@keyframes bp-biz {
0%, 29% {
display: none;
}
30%, 69% {
display: grid;
}
70%, 100% {
display: none;
}
}
@keyframes bp-biz-in {
0%, 30% {
transform: translateX(-12px) scaleX(0.72);
}
44%, 69% {
transform: translateX(0) scaleX(1);
}
70%, 100% {
transform: translateX(-12px) scaleX(0.72);
}
}
@keyframes bp-pick-p {
0%, 29% {
background: #fff7e6;
color: #c93000;
}
30%, 69% {
background: rgb(93.7405490196%, 37.9915294118%, 22.9474509804%);
color: #17141a;
}
70%, 100% {
background: #fff7e6;
color: #c93000;
}
}
@keyframes bp-pick-b {
0%, 29% {
background: rgb(93.7405490196%, 37.9915294118%, 22.9474509804%);
color: #17141a;
}
30%, 69% {
background: #fff7e6;
color: #c93000;
}
70%, 100% {
background: rgb(93.7405490196%, 37.9915294118%, 22.9474509804%);
color: #17141a;
}
}
@keyframes bp-pop-b {
0%, 30% {
transform: scale(1);
}
37% {
transform: scale(1.07);
}
47%, 100% {
transform: scale(1);
}
}
@keyframes bp-pop-p {
0%, 70% {
transform: scale(1);
}
77% {
transform: scale(1.07);
}
87%, 100% {
transform: scale(1);
}
}
.bp.is-demo .bp__steps {
animation-name: bp-cols;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.bp.is-demo .bp__s--biz {
animation-name: bp-biz, bp-biz-in;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite, infinite;
}
.bp.is-demo .bp__opt-b[data-opt=personal] {
animation-name: bp-pick-p, bp-pop-p;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite, infinite;
}
.bp.is-demo .bp__opt-b[data-opt=business] {
animation-name: bp-pick-b, bp-pop-b;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite, infinite;
}
@media (max-width: 340px) {
.bp {
padding: 8px;
font-size: 11px;
}
.bp__pick {
gap: 4px;
}
.bp__opt-b {
gap: 0;
font-size: 10px;
}
.bp__note {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp.is-demo .bp__steps,
.bp.is-demo .bp__s--biz,
.bp.is-demo .bp__opt-b {
animation: none !important;
transform: none !important;
}
.bp.is-demo .bp__steps {
grid-template-columns: repeat(4, 1fr) !important;
}
.bp.is-demo .bp__s--biz {
display: grid !important;
}
.bp.is-demo .bp__opt-b[data-opt=business] {
background: #fff7e6 !important;
color: #c93000 !important;
}
.bp.is-demo .bp__opt-b[data-opt=personal] {
background: rgb(93.7405490196%, 37.9915294118%, 22.9474509804%) !important;
color: #17141a !important;
}
}// 실제 동작: 고른 값으로 단계 배열을 다시 걸러 목록을 통째로 다시 그린다.
// 칸 수가 달라지므로 grid 트랙 수도 배열 길이로 다시 적는다. 번호는 CSS counter 가 센다.
var bp = document.getElementById('bp');
var bpSteps = document.getElementById('bp-steps');
var bpSrc = [].slice.call(document.getElementById('bp-src').content.querySelectorAll('li'));
var bpAt = 2;
function bpDraw() {
var picked = bp.querySelector('.bp__r:checked');
var biz = !!picked && picked.value === 'business';
var list = bpSrc.filter(function (n) { return n.getAttribute('data-k') === 'all' || biz; });
bpSteps.style.gridTemplateColumns = 'repeat(' + list.length + ', 1fr)';
while (bpSteps.firstChild) { bpSteps.removeChild(bpSteps.firstChild); }
list.forEach(function (n, i) {
var li = document.createElement('li');
li.className = 'bp__s';
if (n.getAttribute('data-k') === 'biz') { li.className += ' bp__s--biz'; }
if (i + 1 === bpAt) { li.setAttribute('aria-current', 'step'); }
var lab = document.createElement('span');
lab.className = 'bp__l';
lab.textContent = n.textContent;
li.appendChild(lab);
bpSteps.appendChild(li);
});
}
[].slice.call(bp.querySelectorAll('.bp__r')).forEach(function (r) {
r.addEventListener('change', function () { bp.classList.remove('is-demo'); bpDraw(); });
});
bpDraw();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./BranchStepPath.module.scss";
export type BranchStep = {
label: string;
/** "all" = every path, "biz" = only the business path */
kind: "all" | "biz";
};
export type BranchStepPathProps = {
steps?: BranchStep[];
personalLabel?: string;
businessLabel?: string;
pickLabel?: string;
groupLabel?: string;
note?: string;
/** 1-based index of the step the user stands on */
current?: number;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
const DEFAULT_STEPS: BranchStep[] = [
{ label: "Verify", kind: "all" },
{ label: "Account", kind: "all" },
{ label: "Business info", kind: "biz" },
{ label: "Done", kind: "all" },
];
function Person() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2" />
<path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56" />
</g>
</svg>
);
}
function Briefcase() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M224 118.31V200a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-81.69A191.14 191.14 0 0 0 128 144a191.1 191.1 0 0 0 96-25.69" opacity=".2" />
<path d="M104 112a8 8 0 0 1 8-8h32a8 8 0 0 1 0 16h-32a8 8 0 0 1-8-8m128-40v128a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V72a16 16 0 0 1 16-16h40v-8a24 24 0 0 1 24-24h48a24 24 0 0 1 24 24v8h40a16 16 0 0 1 16 16M96 56h64v-8a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8ZM40 72v41.62A184.1 184.1 0 0 0 128 136a184 184 0 0 0 88-22.39V72Zm176 128v-68.37A200.25 200.25 0 0 1 128 152a200.2 200.2 0 0 1-88-20.36V200z" />
</g>
</svg>
);
}
export default function BranchStepPath({
steps = DEFAULT_STEPS,
personalLabel = "Personal",
businessLabel = "Business",
pickLabel = "Account type",
groupLabel = "Signup path that branches on the answer you pick",
note = "Only the steps that fit the type you picked are left",
current = 2,
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: BranchStepPathProps) {
const [biz, setBiz] = useState(true);
const [live, setLive] = useState(demo);
// 고른 값으로 배열을 다시 걸러 목록을 통째로 다시 그린다
const list = steps.filter((s) => s.kind === "all" || biz);
function pick(next: boolean) {
setLive(false);
setBiz(next);
}
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cols: CSSProperties = { gridTemplateColumns: `repeat(${list.length}, 1fr)` };
const cls = [styles.bp, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<div className={styles.pick} role="radiogroup" aria-label={pickLabel}>
<label className={styles.opt}>
<input
className={styles.r}
type="radio"
name="bp-kind"
value="personal"
checked={!biz}
onChange={() => pick(false)}
/>
<span className={styles.optB} data-opt="personal">
<span className={styles.optI} aria-hidden="true"><Person /></span>
<span>{personalLabel}</span>
</span>
</label>
<label className={styles.opt}>
<input
className={styles.r}
type="radio"
name="bp-kind"
value="business"
checked={biz}
onChange={() => pick(true)}
/>
<span className={styles.optB} data-opt="business">
<span className={styles.optI} aria-hidden="true"><Briefcase /></span>
<span>{businessLabel}</span>
</span>
</label>
</div>
<ol className={styles.steps} style={cols}>
{list.map((s, i) => (
<li
key={s.label}
className={[styles.s, s.kind === "biz" ? styles.sBiz : ""].filter(Boolean).join(" ")}
aria-current={i + 1 === current ? "step" : undefined}
>
<span className={styles.l}>{s.label}</span>
</li>
))}
</ol>
<p className={styles.note}>{note}</p>
</section>
);
}03 · 選んだ答えで分かれるステップの経路 — 計測 累積面積 7.863% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
今いる枠にだけ aria-current="step" が付きます。
function bpDraw() {
var picked = bp.querySelector('.bp__r:checked');
var biz = !!picked && picked.value === 'business';
var list = bpSrc.filter(function (n) {
return n.getAttribute('data-k') === 'all' || biz;
});
bpSteps.style.gridTemplateColumns =
'repeat(' + list.length + ', 1fr)';
list.forEach(function (n, i) {
if (i + 1 === bpAt) li.setAttribute('aria-current', 'step');
});
}
04書きかけの位置を覚えて続きから戻す帯
何が見えるか
長い申込書はたいてい途中で離脱されます。この部品は入力のたびにステップ番号と値を localStorage に書き込み、戻ってきた人に「ステップ3から続ける」と書かれた帯を上から降ろします。続けるを押すと、保存してあったステップと値が本当にフォームへ蘇ります。

<main class="stage">
<section class="rb is-demo" id="rb" aria-label="書きかけの位置を覚える申込書">
<div class="rb__bar" id="rb-bar" role="status" aria-live="polite">
<span class="rb__bar-i" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M136 80v43.47l36.12 21.67a8 8 0 0 1-8.24 13.72l-40-24A8 8 0 0 1 120 128V80a8 8 0 0 1 16 0m-8-48a95.44 95.44 0 0 0-67.92 28.15C52.81 67.51 46.35 74.59 40 82V64a8 8 0 0 0-16 0v40a8 8 0 0 0 8 8h40a8 8 0 0 0 0-16H49c7.15-8.42 14.27-16.35 22.39-24.57a80 80 0 1 1 1.66 114.75a8 8 0 1 0-11 11.64A96 96 0 1 0 128 32"/></g></svg></span>
<p class="rb__bar-t">書きかけの入力があります</p>
<span class="rb__bar-a">
<button class="rb__b rb__b--go" type="button" id="rb-resume" data-t="ステップ{n}から続ける">ステップ3から続ける</button>
<button class="rb__b" type="button" id="rb-fresh">最初から書く</button>
</span>
</div>
<div class="rb__form">
<div class="rb__head">
<p class="rb__h">
<span class="rb__now"><b class="rb__d" data-d="1">1</b><b class="rb__d" data-d="3">3</b></span> / 4 · 申込書
</p>
<p class="rb__save" id="rb-save">自動保存されました</p>
</div>
<div class="rb__dots" aria-hidden="true">
<span class="rb__dot" data-dot="1"><span class="rb__dot-f"></span></span>
<span class="rb__dot" data-dot="2"><span class="rb__dot-f"></span></span>
<span class="rb__dot" data-dot="3"><span class="rb__dot-f"></span></span>
<span class="rb__dot" data-dot="4"><span class="rb__dot-f"></span></span>
</div>
<input class="rb__in" id="rb-in" type="text" placeholder="宛名" aria-label="宛名">
</div> </section>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
$line: color.mix($subject-ink, $stage-paper, 18%);
$soft: color.mix($subject-ink, $stage-paper, 6%);
$mute: rgba($subject-ink, .72);
.rb {
box-sizing: border-box;
width: min(312px, 92vw);
padding: $sp-3;
border: 1px solid $line;
border-radius: $r-card;
background: $stage-paper;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
// ── 위에서 내려오는 이어쓰기 띠 ─────────────────────
// 띠 자체는 루프에서 사라지지 않는다(지운 뒤 빈 화면이 대표 이미지가 된다)
.rb__bar {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2;
border-radius: $r-control;
background: $soft;
}
.rb__bar-i { display: inline-flex; flex: 0 0 auto; width: 18px; height: 18px; color: $color; }
.rb__bar-t {
flex: 1 1 auto;
margin: 0;
color: $subject-ink;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
}
.rb__bar-a {
display: inline-flex;
gap: $sp-1;
}
.rb__b {
margin: 0;
padding: $sp-1 $sp-2;
border: 1px solid $line;
border-radius: $r-pill;
background: transparent;
color: $mute;
font-family: inherit;
font-size: 10px;
font-weight: 700;
line-height: 1.3;
white-space: nowrap;
cursor: pointer;
}
.rb__b--go { border-color: $subject-blue-deep; background: $subject-blue-deep; color: $subject-cream; }
.rb__b:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// ── 아래 폼 ───────────────────────────────────────
.rb__form { padding-top: $sp-3; }
.rb__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: $sp-2;
}.rb__h {
margin: 0;
color: $mute;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
}
.rb__now { position: relative; display: inline-block; width: 12px; }
.rb__d { position: absolute; inset: 0; color: $subject-ink; font-size: 11px; }
.rb__d[data-d="3"] { visibility: hidden; }
// 정적(사람이 조작하는) 경로 — 이어서를 누르면 3단계 표시가 켜진다
.rb.is-at3 .rb__d[data-d="1"] { visibility: hidden; }
.rb.is-at3 .rb__d[data-d="3"] { visibility: visible; }
.rb__save {
margin: 0;
color: $mute;
font-size: 10px;
line-height: 1.3;
opacity: 0;
}
// 칸을 칠 때마다 저장 표시가 떴다 가라앉는다
.rb.is-on .rb__save { opacity: 1; }
.rb.is-fresh .rb__save { opacity: 0; }
.rb__dots {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: $sp-1;
margin-top: $sp-2;
}
.rb__dot {
height: 4px;
border-radius: $r-pill;
background: $soft;
overflow: hidden;
}
.rb__dot-f {
display: block;
height: 100%;
border-radius: inherit;
background: $color;
transform: scaleX(0);
transform-origin: left center; // 규칙8: 단계 점 좌측에서 채움
transition: transform $duration $easing;
}
// 정적 경로 — 이어서를 누른 단계까지의 점이 차 있다
.rb.is-at3 .rb__dot[data-dot="1"] .rb__dot-f,
.rb.is-at3 .rb__dot[data-dot="2"] .rb__dot-f,
.rb.is-at3 .rb__dot[data-dot="3"] .rb__dot-f { transform: scaleX(1); }
.rb__in {
box-sizing: border-box;
width: 100%;
height: 24px;
margin: $sp-2 0 0;
padding: 0 $sp-2;
border: 1px solid $line;
border-radius: $r-sm;
background: $stage-paper;
color: $subject-ink;
font-family: inherit;
font-size: 11px;
line-height: 1.3;
}
.rb__in:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// ── 격자 미리보기 전용 2초 루프 ─────────────────────
// 띠는 내려와 자리 잡고 그대로 남는다(퇴장 재현 금지).
// 띠 아래 폼의 단계 표시가 1 → 3 으로 컷 점프하고 점이 따라 찬다
@keyframes rb-drop {
0% { transform: translateY(-30px); opacity: 0; }
14%, 100% { transform: translateY(0); opacity: 1; }
}
@keyframes rb-ico {
0%, 5% { transform: scale(1); }
11% { transform: scale(1.2); }
18%, 100% { transform: scale(1); }
}
@keyframes rb-d3 {
0%, 44% { visibility: hidden; }
45%, 100% { visibility: visible; }
}
@keyframes rb-d1 {
0%, 44% { visibility: visible; }
45%, 100% { visibility: hidden; }
}
@keyframes rb-dot3 {
0%, 44% { transform: scaleX(0); }
52%, 100% { transform: scaleX(1); }
}
@keyframes rb-in-pop {
0%, 44% { transform: translateY(0); }
50% { transform: translateY(2px); }
58%, 100% { transform: translateY(0); }
}
.rb.is-demo {
.rb__bar {
animation-name: rb-drop;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.rb__bar-i {
animation-name: rb-ico;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.rb__d[data-d="1"] { animation-name: rb-d1; }
.rb__d[data-d="3"] { animation-name: rb-d3; }
.rb__d {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rb__dot[data-dot="3"] .rb__dot-f { animation-name: rb-dot3; }
.rb__dot[data-dot="3"] .rb__dot-f {
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.rb__in {
animation-name: rb-in-pop;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.rb { padding: $sp-2; }
.rb__bar { gap: $sp-1; padding: $sp-1; }
.rb__b { padding: 0 $sp-1; }
.rb__bar-t { font-size: 10px; }
.rb__form { padding-top: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.rb.is-demo .rb__bar,
.rb.is-demo .rb__bar-i,
.rb.is-demo .rb__d,
.rb.is-demo .rb__dot-f,
.rb.is-demo .rb__in { animation: none !important; transform: none !important; }
// 움직임만 끄고 내려온 띠와 3단계 판정은 남긴다
.rb.is-demo .rb__bar { opacity: 1 !important; }
.rb.is-demo .rb__d[data-d="1"] { visibility: hidden !important; }
.rb.is-demo .rb__d[data-d="3"] { visibility: visible !important; }
.rb.is-demo .rb__dot[data-dot="3"] .rb__dot-f { transform: scaleX(1) !important; }
}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);
}
.rb {
box-sizing: border-box;
width: min(312px, 92vw);
padding: 12px;
border: 1px solid rgb(83.6235294118%, 83.4117647059%, 83.8352941176%);
border-radius: 16px;
background: #ffffff;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.rb__bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 12px;
background: rgb(94.5411764706%, 94.4705882353%, 94.6117647059%);
}
.rb__bar-i {
display: inline-flex;
flex: 0 0 auto;
width: 18px;
height: 18px;
color: #2f6df6;
}
.rb__bar-t {
flex: 1 1 auto;
margin: 0;
color: #17141a;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
}
.rb__bar-a {
display: inline-flex;
gap: 4px;
}
.rb__b {
margin: 0;
padding: 4px 8px;
border: 1px solid rgb(83.6235294118%, 83.4117647059%, 83.8352941176%);
border-radius: 999px;
background: transparent;
color: rgba(23, 20, 26, 0.72);
font-family: inherit;
font-size: 10px;
font-weight: 700;
line-height: 1.3;
white-space: nowrap;
cursor: pointer;
}
.rb__b--go {
border-color: #0a4fe8;
background: #0a4fe8;
color: #fff7e6;
}
.rb__b:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.rb__form {
padding-top: 12px;
}
.rb__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.rb__h {
margin: 0;
color: rgba(23, 20, 26, 0.72);
font-size: 11px;
font-weight: 700;
line-height: 1.3;
}
.rb__now {
position: relative;
display: inline-block;
width: 12px;
}
.rb__d {
position: absolute;
inset: 0;
color: #17141a;
font-size: 11px;
}
.rb__d[data-d="3"] {
visibility: hidden;
}
.rb.is-at3 .rb__d[data-d="1"] {
visibility: hidden;
}
.rb.is-at3 .rb__d[data-d="3"] {
visibility: visible;
}
.rb__save {
margin: 0;
color: rgba(23, 20, 26, 0.72);
font-size: 10px;
line-height: 1.3;
opacity: 0;
}
.rb.is-on .rb__save {
opacity: 1;
}
.rb.is-fresh .rb__save {
opacity: 0;
}
.rb__dots {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
margin-top: 8px;
}
.rb__dot {
height: 4px;
border-radius: 999px;
background: rgb(94.5411764706%, 94.4705882353%, 94.6117647059%);
overflow: hidden;
}
.rb__dot-f {
display: block;
height: 100%;
border-radius: inherit;
background: #2f6df6;
transform: scaleX(0);
transform-origin: left center;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rb.is-at3 .rb__dot[data-dot="1"] .rb__dot-f,
.rb.is-at3 .rb__dot[data-dot="2"] .rb__dot-f,
.rb.is-at3 .rb__dot[data-dot="3"] .rb__dot-f {
transform: scaleX(1);
}
.rb__in {
box-sizing: border-box;
width: 100%;
height: 24px;
margin: 8px 0 0;
padding: 0 8px;
border: 1px solid rgb(83.6235294118%, 83.4117647059%, 83.8352941176%);
border-radius: 8px;
background: #ffffff;
color: #17141a;
font-family: inherit;
font-size: 11px;
line-height: 1.3;
}
.rb__in:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
@keyframes rb-drop {
0% {
transform: translateY(-30px);
opacity: 0;
}
14%, 100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes rb-ico {
0%, 5% {
transform: scale(1);
}
11% {
transform: scale(1.2);
}
18%, 100% {
transform: scale(1);
}
}
@keyframes rb-d3 {
0%, 44% {
visibility: hidden;
}
45%, 100% {
visibility: visible;
}
}
@keyframes rb-d1 {
0%, 44% {
visibility: visible;
}
45%, 100% {
visibility: hidden;
}
}
@keyframes rb-dot3 {
0%, 44% {
transform: scaleX(0);
}
52%, 100% {
transform: scaleX(1);
}
}
@keyframes rb-in-pop {
0%, 44% {
transform: translateY(0);
}
50% {
transform: translateY(2px);
}
58%, 100% {
transform: translateY(0);
}
}
.rb.is-demo .rb__bar {
animation-name: rb-drop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.rb.is-demo .rb__bar-i {
animation-name: rb-ico;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.rb.is-demo .rb__d[data-d="1"] {
animation-name: rb-d1;
}
.rb.is-demo .rb__d[data-d="3"] {
animation-name: rb-d3;
}
.rb.is-demo .rb__d {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rb.is-demo .rb__dot[data-dot="3"] .rb__dot-f {
animation-name: rb-dot3;
}
.rb.is-demo .rb__dot[data-dot="3"] .rb__dot-f {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.rb.is-demo .rb__in {
animation-name: rb-in-pop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.rb {
padding: 8px;
}
.rb__bar {
gap: 4px;
padding: 4px;
}
.rb__b {
padding: 0 4px;
}
.rb__bar-t {
font-size: 10px;
}
.rb__form {
padding-top: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.rb.is-demo .rb__bar,
.rb.is-demo .rb__bar-i,
.rb.is-demo .rb__d,
.rb.is-demo .rb__dot-f,
.rb.is-demo .rb__in {
animation: none !important;
transform: none !important;
}
.rb.is-demo .rb__bar {
opacity: 1 !important;
}
.rb.is-demo .rb__d[data-d="1"] {
visibility: hidden !important;
}
.rb.is-demo .rb__d[data-d="3"] {
visibility: visible !important;
}
.rb.is-demo .rb__dot[data-dot="3"] .rb__dot-f {
transform: scaleX(1) !important;
}
}// 실제 동작: 칸을 칠 때마다 단계 번호와 값을 localStorage 에 적고,
// 열 때 저장된 게 있으면 그 단계로 데려다 준다. iframe 이 저장소를 막을 수 있어 try/catch.
var rb = document.getElementById('rb');
var rbIn = document.getElementById('rb-in');
var rbGo = document.getElementById('rb-resume');
var rbMark = document.getElementById('rb-save');
var RB_KEY = 'godrich-265-04';
var rbStep = 1;
function rbRead() {
try { return JSON.parse(localStorage.getItem(RB_KEY) || 'null'); } catch (e) { return null; }
}
function rbWrite(o) {
try { localStorage.setItem(RB_KEY, JSON.stringify(o)); } catch (e) {}
}
function rbAt(n) { rbStep = n; rb.classList.toggle('is-at3', n >= 3); }
var rbDraft = rbRead();
if (!rbDraft) {
// 데모라 직전 세션이 없다 — 이어쓸 거리가 있도록 예시 초안을 한 번 적어 둔다
rbDraft = { step: 3, v: '' };
rbWrite(rbDraft);
}
rbGo.textContent = rbGo.getAttribute('data-t').replace('{n}', rbDraft.step);
rbIn.addEventListener('input', function () {
rb.classList.remove('is-demo');
rbWrite({ step: rbStep, v: rbIn.value });
rbMark.classList.add('is-on');
});
rbGo.addEventListener('click', function () {
rb.classList.remove('is-demo');
var d = rbRead();
rbAt(d ? d.step : 1);
rbIn.value = d ? d.v : '';
rbIn.focus({ preventScroll: true });
});
document.getElementById('rb-fresh').addEventListener('click', function () {
rb.classList.remove('is-demo');
rbWrite({ step: 1, v: '' });
rb.classList.add('is-fresh');
rbAt(1);
rbIn.value = '';
rbIn.focus({ preventScroll: true });
});
rbAt(1);import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ResumeDraftBanner.module.scss";
export type ResumeDraftBannerProps = {
bannerText?: string;
resumePrefix?: string;
resumeSuffix?: string;
freshLabel?: string;
headingPrefix?: string;
headingSuffix?: string;
fieldLabel?: string;
fieldPlaceholder?: string;
savedLabel?: string;
groupLabel?: string;
totalSteps?: number;
/** 데모라 직전 세션이 없을 때 이어쓸 초안 단계 */
seedStep?: number;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type KeyEvt = { key: string; preventDefault(): void };
type InputEvt = { target: { value: string } };
type Box<T> = { current: T };
const STORAGE_KEY = "godrich-265-04-react";
type Draft = { step: number; v: string };
function readDraft(seedStep: number): Draft {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const d = JSON.parse(raw) as Draft;
if (typeof d.step === "number") return { step: d.step, v: String(d.v || "") };
}
} catch {
// iframe 이 저장소를 막을 수 있다 — 없을 때와 똑같이 논다
}
return { step: seedStep, v: "" };
}
function writeDraft(d: Draft) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(d));
} catch {
// 저장 실패는 화면을 깨지 않는다
}
}
function ClockBack() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2" />
<path d="M136 80v43.47l36.12 21.67a8 8 0 0 1-8.24 13.72l-40-24A8 8 0 0 1 120 128V80a8 8 0 0 1 16 0m-8-48a95.44 95.44 0 0 0-67.92 28.15C52.81 67.51 46.35 74.59 40 82V64a8 8 0 0 0-16 0v40a8 8 0 0 0 8 8h40a8 8 0 0 0 0-16H49c7.15-8.42 14.27-16.35 22.39-24.57a80 80 0 1 1 1.66 114.75a8 8 0 1 0-11 11.64A96 96 0 1 0 128 32" />
</g>
</svg>
);
}
export default function ResumeDraftBanner({
bannerText = "You have an unfinished draft",
resumePrefix = "Resume from step ",
resumeSuffix = "",
freshLabel = "Start over",
headingPrefix = "/ 4 · Application",
headingSuffix = "",
fieldLabel = "Recipient name",
fieldPlaceholder = "Recipient name",
savedLabel = "Saved automatically",
groupLabel = "Application that remembers where you stopped",
totalSteps = 4,
seedStep = 3,
demo = true,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: ResumeDraftBannerProps) {
const [draft, setDraft] = useState<Draft>({ step: 1, v: "" });
const [step, setStep] = useState(1);
const [live, setLive] = useState(demo);
const [saved, setSaved] = useState(false);
const input = useRef<HTMLInputElement>(null) as Box<HTMLInputElement | null>;
// 열 때 저장된 초안을 읽어 띠 문구를 맞춘다
useEffect(() => {
const d = readDraft(seedStep);
setDraft(d);
if (!localStorage.getItem(STORAGE_KEY)) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(d));
} catch {
/* 저장소가 막혀도 띠는 seedStep 로 보여 준다 */
}
}
}, [seedStep]);
function onInput(e: InputEvt) {
setLive(false);
setSaved(true);
const next = { step, v: e.target.value };
setDraft(next);
writeDraft(next);
}
function resume() {
setLive(false);
const d = readDraft(seedStep);
setStep(d.step);
setDraft(d);
if (input.current) {
input.current.value = d.v;
input.current.focus({ preventScroll: true });
}
}
function fresh() {
setLive(false);
const next = { step: 1, v: "" };
setStep(1);
setDraft(next);
setSaved(false);
writeDraft(next);
if (input.current) {
input.current.value = "";
input.current.focus({ preventScroll: true });
}
}
function onKey(e: KeyEvt) {
if (e.key === "Enter") {
e.preventDefault();
resume();
}
}
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = [styles.rb, live ? styles.isDemo : "", step >= 3 ? styles.isAt3 : "", saved ? styles.isOn : ""]
.filter(Boolean)
.join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<div className={styles.bar} role="status" aria-live="polite">
<span className={styles.barI} aria-hidden="true"><ClockBack /></span>
<p className={styles.barT}>{bannerText}</p>
<span className={styles.barA}>
<button className={[styles.b, styles.bGo].join(" ")} type="button" onClick={resume}>
{resumePrefix + draft.step + resumeSuffix}
</button>
<button className={styles.b} type="button" onClick={fresh}>
{freshLabel}
</button>
</span>
</div>
<div className={styles.form}>
<div className={styles.head}>
<p className={styles.h}>
<span className={styles.now}>
<b className={styles.d} data-d="1">1</b>
<b className={styles.d} data-d="3">3</b>
</span>
{" "}{headingPrefix}{headingSuffix}
</p>
<p className={styles.save}>{savedLabel}</p>
</div>
<div className={styles.dots} aria-hidden="true">
{Array.from({ length: totalSteps }, (_, i) => (
<span key={i} className={styles.dot} data-dot={i + 1}>
<span className={styles.dotF} />
</span>
))}
</div>
<input
ref={input}
className={styles.in}
type="text"
placeholder={fieldPlaceholder}
aria-label={fieldLabel}
onInput={onInput}
onKeyDown={onKey}
/>
</div>
</section>
);
}04 · 書きかけの位置を覚えて続きから戻す帯 — 計測 累積面積 10.174% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
iframe の中では保存が拒否され得るため、アクセスはすべて try/catch で包みました。
function rbRead() {
try {
return JSON.parse(localStorage.getItem(RB_KEY) || 'null');
} catch (e) { return null; }
}
function rbWrite(o) {
try { localStorage.setItem(RB_KEY, JSON.stringify(o)); } catch (e) {}
}
rbGo.textContent = rbGo.getAttribute('data-t')
.replace('{n}', rbDraft.step);
05打つそばから積み上がる横の要約
画面で起きること
決済画面で一番不安なのは、自分が選んだものが見えない瞬間です。左の欄に文字を入れたその瞬間、右の要約行が同じように変わり、要約の横の修正ボタンを押すとその値を持つステップへ直接戻って該当欄にフォーカスを移します。

<main class="stage">
<section class="sp is-demo" id="sp" aria-label="お届け情報の入力ステップ">
<div class="sp__bar">
<p class="sp__h">お届け情報</p>
<p class="sp__cnt" id="sp-step">ステップ 2 / 3</p>
</div>
<div class="sp__cols">
<div class="sp__form">
<div class="sp__f" data-f="1">
<span class="sp__num" aria-hidden="true">1</span>
<input class="sp__inp" id="sp-i1" value="ホン・ギルドン" aria-label="宛名" placeholder="ホン・ギルドン">
</div>
<div class="sp__f is-current" data-f="2">
<span class="sp__num" aria-hidden="true">2</span>
<input class="sp__inp" id="sp-i2" value="[email protected]" aria-label="メール" placeholder="[email protected]">
</div>
<div class="sp__f" data-f="3">
<span class="sp__num" aria-hidden="true">3</span>
<input class="sp__inp" id="sp-i3" value="090-2345-6789" aria-label="電話番号" placeholder="090-2345-6789">
</div>
</div>
<div class="sp__side" aria-live="polite">
<ul class="sp__list">
<li class="sp__row" data-row="1">
<span class="sp__k">宛名</span>
<span class="sp__v"><span class="sp__v0">—</span><span class="sp__v1">ホン・ギルドン</span></span>
<button class="sp__fix" type="button">修正</button>
</li>
<li class="sp__row" data-row="2">
<span class="sp__k">メール</span>
<span class="sp__v"><span class="sp__v0">—</span><span class="sp__v1">[email protected]</span></span>
<button class="sp__fix" type="button">修正</button>
</li>
<li class="sp__row" data-row="3">
<span class="sp__k">電話番号</span>
<span class="sp__v"><span class="sp__v0">—</span><span class="sp__v1">090-2345-6789</span></span>
<button class="sp__fix" type="button">修正</button>
</li>
</ul>
</div>
</div>
</section>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
$line: color.mix($subject-cream, $stage-ink, 16%);
$soft: color.mix($subject-cream, $stage-ink, 8%);
.sp {
box-sizing: border-box;
width: min(320px, 92vw);
padding: $sp-3;
border: 1px solid $line;
border-radius: $r-card;
background: $stage-ink;
color: $subject-cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.sp__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: $sp-2;
margin: 0 0 $sp-2;
}
.sp__h { margin: 0; font-size: 11px; font-weight: 700; letter-spacing: .04em; color: rgba(255, 255, 255, .56); }
.sp__cnt { margin: 0; font-size: 11px; font-weight: 700; color: $subject-blue-light; }
.sp__cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $sp-3;
}
// ── 왼쪽: 단계 칸 ────────────────────────────────
.sp__form { display: grid; gap: $sp-2; align-content: start; }
.sp__f {
display: grid;
grid-template-columns: auto 1fr;
gap: $sp-2;
align-items: center;
}
.sp__num {
display: grid;
place-items: center;
width: 16px;
height: 16px;
border: 1px solid $line;
border-radius: 50%;
font-size: 10px;
font-weight: 700;
color: rgba(255, 255, 255, .5);
transition: background-color $duration $easing, border-color $duration $easing, color $duration $easing;
}
.sp__inp {
box-sizing: border-box;
width: 100%;
min-width: 0;
margin: 0;
padding: $sp-1;
border: 1px solid $line;
border-radius: $r-sm;
background: $soft;
color: $subject-cream;
font-family: inherit;
font-size: 12px;
line-height: 1.3;
}
.sp__inp:focus-visible { outline: 2px solid $color; outline-offset: 1px; }
// 현재 단계 표시 — 고치기로 돌아온 단계가 어디인지 번호와 테두리로 보인다
.sp__f.is-current .sp__num { border-color: $subject-blue-deep; background: $subject-blue-deep; color: $subject-cream; }
.sp__f.is-current .sp__inp { border-color: $color; }
// ── 오른쪽: 요약 패널 ────────────────────────────
.sp__side { min-width: 0; border-left: 1px solid $line; padding-left: $sp-2; }
.sp__list { display: grid; gap: $sp-2; margin: 0; padding: 0; list-style: none; align-content: start; }
.sp__row {
display: grid;
grid-template-columns: 1fr auto;
gap: 0 $sp-1;
align-items: center;
}
.sp__k { grid-column: 1 / -1; font-size: 10px; color: rgba(255, 255, 255, .72); }
.sp__v { position: relative; display: block; min-width: 0; font-size: 11px; }
.sp__v0, .sp__v1 { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.sp__v0 { color: rgba(255, 255, 255, .34); }
.sp__v1 { position: absolute; inset: 0; color: $subject-cream; }
// 정적 상태(사람이 조작하는 길) — 요약 줄에는 이미 값이 들어와 있다
.sp__v0 { opacity: 0; }
.sp__v1 { opacity: 1; }
.sp__fix {
margin: 0;
padding: 0 $sp-1;
border: 1px solid $line;
border-radius: $r-pill;
background: transparent;
color: rgba(255, 255, 255, .56);
font-family: inherit;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
transition: border-color $duration $easing, color $duration $easing;
}
.sp__fix:hover { border-color: $color; color: $subject-cream; }
.sp__fix:focus-visible { outline: 2px solid $color; outline-offset: 1px; }
// 격자 미리보기 동안 플레이스홀더가 값과 겹쳐 보이지 않게
.sp.is-demo .sp__inp::placeholder { color: transparent; }
// ── 격자 미리보기 전용 2초 루프 ──────────────────
// 값 글자는 steps(1, end) 로 한 프레임에 컷 교체되고(반투명 유령 금지),
// 같은 순간 translateX 로 오른쪽에서 미끄러져 들어와 프레임 수를 채운다
// 값은 한 번 들어오면 루프 끝까지 유지되고, 처음 상태로 돌아가는 순간(컷)은
// 애니메이션 경계(100% → 0%) 자체다 — 루프 안에서 주인공(요약 값)을 지우지 않는다
@keyframes sp-cut-1 {
0%, 13% { opacity: 0; }
14%, 100% { opacity: 1; }
}
@keyframes sp-cut-2 {
0%, 43% { opacity: 0; }
44%, 100% { opacity: 1; }
}
@keyframes sp-cut-3 {
0%, 73% { opacity: 0; }
74%, 100% { opacity: 1; }
}
@keyframes sp-empty-1 {
0%, 13% { opacity: 1; }
14%, 100% { opacity: 0; }
}
@keyframes sp-empty-2 {
0%, 43% { opacity: 1; }
44%, 100% { opacity: 0; }
}
@keyframes sp-empty-3 {
0%, 73% { opacity: 1; }
74%, 100% { opacity: 0; }
}
@keyframes sp-slide-1 {
0%, 12% { transform: translateX(0); }
14% { transform: translateX(10px); }
28%, 100% { transform: translateX(0); }
}
@keyframes sp-slide-2 {
0%, 42% { transform: translateX(0); }
44% { transform: translateX(10px); }
58%, 100% { transform: translateX(0); }
}
@keyframes sp-slide-3 {
0%, 72% { transform: translateX(0); }
74% { transform: translateX(10px); }
88%, 100% { transform: translateX(0); }
}
.sp.is-demo {
.sp__row[data-row="1"] .sp__v1 { animation-name: sp-cut-1, sp-slide-1; }
.sp__row[data-row="2"] .sp__v1 { animation-name: sp-cut-2, sp-slide-2; }
.sp__row[data-row="3"] .sp__v1 { animation-name: sp-cut-3, sp-slide-3; }
.sp__v1 {
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
}
.sp__row[data-row="1"] .sp__v0 { animation-name: sp-empty-1; }
.sp__row[data-row="2"] .sp__v0 { animation-name: sp-empty-2; }
.sp__row[data-row="3"] .sp__v0 { animation-name: sp-empty-3; }
.sp__v0 {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
}
// 320px 에서는 요약을 아래로 내리지 않고 폭만 줄인다 — 세로 예산(174px) 때문
@media (max-width: 340px) {
.sp { padding: $sp-2; }
.sp__bar { margin-bottom: $sp-1; }
.sp__cols { gap: $sp-2; }
.sp__form, .sp__list { gap: $sp-1; }
.sp__f { gap: $sp-1; }
.sp__inp { padding: 0 $sp-1; font-size: 11px; }
.sp__side { padding-left: $sp-1; }
.sp__fix { padding: 0; border: 0; }
}
@media (prefers-reduced-motion: reduce) {
.sp.is-demo .sp__v0,
.sp.is-demo .sp__v1 { animation: none !important; transform: none !important; }
// 움직임만 끄고 판정 결과(값이 들어와 있는 요약)는 정적 규칙 그대로 남는다
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sp {
box-sizing: border-box;
width: min(320px, 92vw);
padding: 12px;
border: 1px solid rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
border-radius: 16px;
background: #17141a;
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.sp__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin: 0 0 8px;
}
.sp__h {
margin: 0;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
color: rgba(255, 255, 255, 0.56);
}
.sp__cnt {
margin: 0;
font-size: 11px;
font-weight: 700;
color: #5b8cf8;
}
.sp__cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.sp__form {
display: grid;
gap: 8px;
align-content: start;
}
.sp__f {
display: grid;
grid-template-columns: auto 1fr;
gap: 8px;
align-items: center;
}
.sp__num {
display: grid;
place-items: center;
width: 16px;
height: 16px;
border: 1px solid rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
border-radius: 50%;
font-size: 10px;
font-weight: 700;
color: rgba(255, 255, 255, 0.5);
transition: background-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1), border-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1), color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sp__inp {
box-sizing: border-box;
width: 100%;
min-width: 0;
margin: 0;
padding: 4px;
border: 1px solid rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
border-radius: 8px;
background: rgb(16.2980392157%, 14.9647058824%, 16.5960784314%);
color: #fff7e6;
font-family: inherit;
font-size: 12px;
line-height: 1.3;
}
.sp__inp:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 1px;
}
.sp__f.is-current .sp__num {
border-color: #0a4fe8;
background: #0a4fe8;
color: #fff7e6;
}
.sp__f.is-current .sp__inp {
border-color: #2f6df6;
}
.sp__side {
min-width: 0;
border-left: 1px solid rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
padding-left: 8px;
}
.sp__list {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
align-content: start;
}
.sp__row {
display: grid;
grid-template-columns: 1fr auto;
gap: 0 4px;
align-items: center;
}
.sp__k {
grid-column: 1/-1;
font-size: 10px;
color: rgba(255, 255, 255, 0.72);
}
.sp__v {
position: relative;
display: block;
min-width: 0;
font-size: 11px;
}
.sp__v0, .sp__v1 {
display: block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.sp__v0 {
color: rgba(255, 255, 255, 0.34);
}
.sp__v1 {
position: absolute;
inset: 0;
color: #fff7e6;
}
.sp__v0 {
opacity: 0;
}
.sp__v1 {
opacity: 1;
}
.sp__fix {
margin: 0;
padding: 0 4px;
border: 1px solid rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
border-radius: 999px;
background: transparent;
color: rgba(255, 255, 255, 0.56);
font-family: inherit;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
transition: border-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1), color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sp__fix:hover {
border-color: #2f6df6;
color: #fff7e6;
}
.sp__fix:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 1px;
}
.sp.is-demo .sp__inp::placeholder {
color: transparent;
}
@keyframes sp-cut-1 {
0%, 13% {
opacity: 0;
}
14%, 100% {
opacity: 1;
}
}
@keyframes sp-cut-2 {
0%, 43% {
opacity: 0;
}
44%, 100% {
opacity: 1;
}
}
@keyframes sp-cut-3 {
0%, 73% {
opacity: 0;
}
74%, 100% {
opacity: 1;
}
}
@keyframes sp-empty-1 {
0%, 13% {
opacity: 1;
}
14%, 100% {
opacity: 0;
}
}
@keyframes sp-empty-2 {
0%, 43% {
opacity: 1;
}
44%, 100% {
opacity: 0;
}
}
@keyframes sp-empty-3 {
0%, 73% {
opacity: 1;
}
74%, 100% {
opacity: 0;
}
}
@keyframes sp-slide-1 {
0%, 12% {
transform: translateX(0);
}
14% {
transform: translateX(10px);
}
28%, 100% {
transform: translateX(0);
}
}
@keyframes sp-slide-2 {
0%, 42% {
transform: translateX(0);
}
44% {
transform: translateX(10px);
}
58%, 100% {
transform: translateX(0);
}
}
@keyframes sp-slide-3 {
0%, 72% {
transform: translateX(0);
}
74% {
transform: translateX(10px);
}
88%, 100% {
transform: translateX(0);
}
}
.sp.is-demo .sp__row[data-row="1"] .sp__v1 {
animation-name: sp-cut-1, sp-slide-1;
}
.sp.is-demo .sp__row[data-row="2"] .sp__v1 {
animation-name: sp-cut-2, sp-slide-2;
}
.sp.is-demo .sp__row[data-row="3"] .sp__v1 {
animation-name: sp-cut-3, sp-slide-3;
}
.sp.is-demo .sp__v1 {
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite, infinite;
}
.sp.is-demo .sp__row[data-row="1"] .sp__v0 {
animation-name: sp-empty-1;
}
.sp.is-demo .sp__row[data-row="2"] .sp__v0 {
animation-name: sp-empty-2;
}
.sp.is-demo .sp__row[data-row="3"] .sp__v0 {
animation-name: sp-empty-3;
}
.sp.is-demo .sp__v0 {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.sp {
padding: 8px;
}
.sp__bar {
margin-bottom: 4px;
}
.sp__cols {
gap: 8px;
}
.sp__form, .sp__list {
gap: 4px;
}
.sp__f {
gap: 4px;
}
.sp__inp {
padding: 0 4px;
font-size: 11px;
}
.sp__side {
padding-left: 4px;
}
.sp__fix {
padding: 0;
border: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.sp.is-demo .sp__v0,
.sp.is-demo .sp__v1 {
animation: none !important;
transform: none !important;
}
}// 실제 동작: 왼쪽 칸에 넣는 글자가 input 이벤트로 그 순간 오른쪽 요약 줄에 반영되고,
// 修正 단추는 그 값을 담은 단계를 현재 단계로 만들어 해당 칸에 초점을 옮긴다.
var sp = document.getElementById('sp');
var STEPS = ['ステップ 1 / 3', 'ステップ 2 / 3', 'ステップ 3 / 3'];
var fs = [].slice.call(sp.querySelectorAll('.sp__f'));
var ins = [].slice.call(sp.querySelectorAll('.sp__inp'));
var rows = [].slice.call(sp.querySelectorAll('.sp__row'));
var cnt = document.getElementById('sp-step');
function setStep(i) {
fs.forEach(function (f, n) { f.classList.toggle('is-current', n === i); });
cnt.textContent = STEPS[i];
}
ins.forEach(function (inp, i) {
inp.addEventListener('input', function () {
sp.classList.remove('is-demo');
rows[i].querySelector('.sp__v1').textContent = inp.value.trim() || '—';
});
inp.addEventListener('focus', function () { sp.classList.remove('is-demo'); setStep(i); });
inp.addEventListener('keydown', function (e) {
var d = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0;
if (d && ins[i + d]) { e.preventDefault(); ins[i + d].focus({ preventScroll: true }); }
});
});
rows.forEach(function (row, i) {
row.querySelector('.sp__fix').addEventListener('click', function () {
sp.classList.remove('is-demo');
setStep(i);
ins[i].focus({ preventScroll: true });
});
});import { useState, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./LiveSummaryPanel.module.scss";
export type LiveSummaryPanelProps = {
heading?: string;
fields?: { label: string; value: string }[];
editLabel?: string;
groupLabel?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type FocusEl = { focus(opt: { preventScroll: boolean }): void };
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
type InpEvt = { target: { value: string } };
const DEFAULT_FIELDS = [
{ label: "Recipient", value: "Hong Gildong" },
{ label: "Email", value: "[email protected]" },
{ label: "Phone", value: "010-2345-6789" },
];
export default function LiveSummaryPanel({
heading = "Delivery details",
fields = DEFAULT_FIELDS,
editLabel = "Edit",
groupLabel = "Delivery details entry steps",
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: LiveSummaryPanelProps) {
// 정적 상태: 요약이 값으로 차 있고 현재 단계는 2 (0 기반 1)
const [values, setValues] = useState<string[]>(fields.map((f) => f.value));
const [at, setAt] = useState(1);
const [live, setLive] = useState(demo);
const boxes = useRef<(FocusEl | null)[]>([]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function type(i: number, v: string) {
setLive(false);
setValues((prev) => prev.map((s, n) => (n === i ? v : s)));
}
// 고치기: 그 값을 담은 단계로 돌아가 해당 칸에 초점을 준다
function jump(i: number) {
setLive(false);
setAt(i);
const arr = boxes.current;
const el = arr ? arr[i] : null;
if (el) el.focus({ preventScroll: true });
}
function arrows(i: number, e: KeyEvt) {
const d = e.key === "ArrowDown" ? 1 : e.key === "ArrowUp" ? -1 : 0;
if (d && fields[i + d]) {
e.preventDefault();
setAt(i + d);
const arr = boxes.current;
const el = arr ? arr[i + d] : null;
if (el) el.focus({ preventScroll: true });
}
}
const cls = [styles.sp, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<div className={styles.bar}>
<p className={styles.h}>{heading}</p>
<p className={styles.cnt}>{at + 1} / {fields.length} steps</p>
</div>
<div className={styles.cols}>
<div className={styles.form}>
{fields.map((f, i) => (
<div
key={f.label}
className={[styles.f, i === at ? styles.isCurrent : ""].filter(Boolean).join(" ")}
data-f={i + 1}
>
<span className={styles.num} aria-hidden="true">{i + 1}</span>
<input
className={styles.inp}
ref={(el: FocusEl | null) => { const arr = boxes.current; if (arr) arr[i] = el; }}
value={values[i]}
aria-label={f.label}
placeholder={f.value}
onChange={(e: InpEvt) => type(i, e.target.value)}
onFocus={() => { setLive(false); setAt(i); }}
onKeyDown={(e: KeyEvt) => arrows(i, e)}
/>
</div>
))}
</div>
<div className={styles.side} aria-live="polite">
<ul className={styles.list}>
{fields.map((f, i) => (
<li key={f.label} className={styles.row} data-row={i + 1}>
<span className={styles.k}>{f.label}</span>
<span className={styles.v}>
<span className={styles.v0}>—</span>
<span className={styles.v1}>{values[i] || "—"}</span>
</span>
<button className={styles.fix} type="button" onClick={() => jump(i)}>{editLabel}</button>
</li>
))}
</ul>
</div>
</div>
</section>
);
}05 · 打つそばから積み上がる横の要約 — 計測 累積面積 1.076% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
要約パネルは aria-live="polite" で囲み、値が変わればスクリーンリーダーが読み直します。
ins.forEach(function (inp, i) {
inp.addEventListener('input', function () {
sp.classList.remove('is-demo');
rows[i].querySelector('.sp__v1').textContent =
inp.value.trim() || '—';
});
});
rows.forEach(function (row, i) {
row.querySelector('.sp__fix').addEventListener('click', function () {
sp.classList.remove('is-demo');
setStep(i);
ins[i].focus({ preventScroll: true });
});
});
06終わったステップが一行に畳まれる縦型ウィザード
どんな動きか
スマホの画面にはステップを横に並べる幅がありません。この部品は縦に積み、値が埋まったステップを grid-template-rows が 1fr から 0fr へ畳んで、見出しの一行だけを残します。

<main class="stage">
<section class="vz is-demo" id="vz" aria-label="お届け情報の3ステップ">
<div class="vz__item is-done" data-item="1">
<button class="vz__h" type="button" id="vz-h1" aria-expanded="false" aria-controls="vz-p1">
<span class="vz__num" aria-hidden="true">1</span>
<span class="vz__t">宛名</span>
<span class="vz__sv">ホン・ギルドン</span>
<span class="vz__caret" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg></span>
</button>
<div class="vz__body" id="vz-p1" role="region" aria-labelledby="vz-h1">
<div class="vz__inner">
<input class="vz__inp" value="ホン・ギルドン" aria-label="宛名" placeholder="ホン・ギルドン">
<button class="vz__next" type="button">次へ</button>
</div>
</div>
</div>
<div class="vz__item is-open" data-item="2">
<button class="vz__h" type="button" id="vz-h2" aria-expanded="true" aria-controls="vz-p2">
<span class="vz__num" aria-hidden="true">2</span>
<span class="vz__t">配送先</span>
<span class="vz__sv">ソウル市江南区駅三洞 12-34</span>
<span class="vz__caret" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg></span>
</button>
<div class="vz__body" id="vz-p2" role="region" aria-labelledby="vz-h2">
<div class="vz__inner">
<input class="vz__inp" value="ソウル市江南区駅三洞 12-34" aria-label="配送先" placeholder="ソウル市江南区駅三洞 12-34">
<button class="vz__next" type="button">次へ</button>
</div>
</div>
</div>
<div class="vz__item" data-item="3">
<button class="vz__h" type="button" id="vz-h3" aria-expanded="false" aria-controls="vz-p3">
<span class="vz__num" aria-hidden="true">3</span>
<span class="vz__t">電話番号</span>
<span class="vz__sv">090-2345-6789</span>
<span class="vz__caret" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg></span>
</button>
<div class="vz__body" id="vz-p3" role="region" aria-labelledby="vz-h3">
<div class="vz__inner">
<input class="vz__inp" value="090-2345-6789" aria-label="電話番号" placeholder="090-2345-6789">
<button class="vz__next" type="button">完了</button>
</div>
</div>
</div>
</section>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-yellow);
$line: color.mix($subject-ink, $stage-yellow, 20%);
$soft: color.mix($subject-ink, $stage-yellow, 8%);
.vz {
box-sizing: border-box;
display: grid;
gap: $sp-1;
width: min(304px, 92vw);
padding: $sp-2;
border: 1px solid $line;
border-radius: $r-card;
background: $stage-yellow;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
transition: border-color $duration $easing;
}
.vz.is-all { border-color: $color; }
.vz__item {
border: 1px solid $line;
border-radius: $r-sm;
background: $soft;
transition: border-color $duration $easing;
}
.vz__item.is-open { border-color: $color; }
// ── 머리: 번호 · 제목 · 값 요약 · 화살표 ──────────
.vz__h {
display: grid;
grid-template-columns: auto 1fr auto auto;
gap: $sp-2;
align-items: center;
width: 100%;
margin: 0;
padding: $sp-2;
border: 0;
background: transparent;
color: inherit;
font-family: inherit;
font-size: 12px;
font-weight: 700;
line-height: 1.3;
text-align: left;
cursor: pointer;
}
.vz__h:focus-visible { outline: 2px solid $color; outline-offset: 1px; }
.vz__num {
display: grid;
place-items: center;
width: 18px;
height: 18px;
border: 1px solid $line;
border-radius: 50%;
font-size: 10px;
color: rgba(23, 20, 26, .72);
transition: background-color $duration $easing, border-color $duration $easing, color $duration $easing;
}
.vz__item.is-open .vz__num { border-color: $color; color: $subject-ink; }
.vz__item.is-done .vz__num { border-color: $subject-blue-deep; background: $subject-blue-deep; color: $subject-cream; }
.vz__t { color: $subject-ink; }
// 끝난 단계에만 머리에 입력값 한 줄이 남는다(펼쳐 있는 동안은 감춘다)
.vz__sv {
max-width: 120px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 11px;
font-weight: 400;
color: rgba(23, 20, 26, .72);
opacity: 0;
transition: opacity $duration $easing;
}
.vz__item.is-done .vz__sv { opacity: 1; }
.vz__item.is-open .vz__sv { opacity: 0; }
.vz__caret {
display: inline-flex;
width: 14px;
height: 14px;
color: rgba(23, 20, 26, .55);
transform: rotate(-90deg);
transition: transform $duration $easing;
}
.vz__item.is-open .vz__caret { transform: rotate(0deg); }
// ── 접히는 몸통: grid-template-rows 1fr ↔ 0fr ─────
// 0fr 에서도 세로 패딩은 남는다 — 몸통에 min-height: 0 + overflow: hidden 래퍼를 두고
// 접힐 때 세로 패딩을 0 으로 함께 내린다. visibility 로 접힌 칸의 초점도 막는다.
.vz__body {
display: grid;
grid-template-rows: 0fr;
visibility: hidden;
transition: grid-template-rows $duration $easing, visibility $duration $easing;
}
.vz__item.is-open .vz__body { grid-template-rows: 1fr; visibility: visible; }
.vz__inner {
display: grid;
grid-template-columns: 1fr auto;
gap: $sp-2;
align-items: center;
min-height: 0;
overflow: hidden;
padding: 0 $sp-2;
transition: padding $duration $easing;
}
.vz__item.is-open .vz__inner { padding: $sp-2; }
.vz__inp {
box-sizing: border-box;
width: 100%;
min-width: 0;
margin: 0;
padding: $sp-1;
border: 1px solid $line;
border-radius: $r-sm;
background: $stage-yellow;
color: $subject-ink;
font-family: inherit;
font-size: 12px;
line-height: 1.3;
}
.vz__inp:focus-visible { outline: 2px solid $color; outline-offset: 1px; }
.vz__next {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: $subject-blue-deep;
color: $subject-cream;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
cursor: pointer;
}
.vz__next:focus-visible { outline: 2px solid $color; outline-offset: 1px; }
// 격자 미리보기 동안 플레이스홀더가 값과 겹쳐 보이지 않게
.vz.is-demo .vz__inp::placeholder { color: transparent; }
// ── 격자 미리보기 전용 2초 루프 ──────────────────
// 1단계 접힘 → 2단계 펼침 → 2단계 접힘 → 3단계 펼침 → 84% 까지 처음으로 복귀
@keyframes vz-rows-1 {
0%, 6% { grid-template-rows: 1fr; visibility: visible; }
16%, 74% { grid-template-rows: 0fr; visibility: hidden; }
84%, 100% { grid-template-rows: 1fr; visibility: visible; }
}
@keyframes vz-rows-2 {
0%, 16% { grid-template-rows: 0fr; visibility: hidden; }
26%, 40% { grid-template-rows: 1fr; visibility: visible; }
50%, 100% { grid-template-rows: 0fr; visibility: hidden; }
}
@keyframes vz-rows-3 {
0%, 50% { grid-template-rows: 0fr; visibility: hidden; }
60%, 72% { grid-template-rows: 1fr; visibility: visible; }
82%, 100% { grid-template-rows: 0fr; visibility: hidden; }
}
@keyframes vz-pad-1 {
0%, 6% { padding: 8px 8px; }
16%, 74% { padding: 0 8px; }
84%, 100% { padding: 8px 8px; }
}
@keyframes vz-pad-2 {
0%, 16% { padding: 0 8px; }
26%, 40% { padding: 8px 8px; }
50%, 100% { padding: 0 8px; }
}
@keyframes vz-pad-3 {
0%, 50% { padding: 0 8px; }
60%, 72% { padding: 8px 8px; }
82%, 100% { padding: 0 8px; }
}
@keyframes vz-caret-1 {
0%, 6% { transform: rotate(0deg); }
16%, 74% { transform: rotate(-90deg); }
84%, 100% { transform: rotate(0deg); }
}
@keyframes vz-caret-2 {
0%, 16% { transform: rotate(-90deg); }
26%, 40% { transform: rotate(0deg); }
50%, 100% { transform: rotate(-90deg); }
}
@keyframes vz-caret-3 {
0%, 50% { transform: rotate(-90deg); }
60%, 72% { transform: rotate(0deg); }
82%, 100% { transform: rotate(-90deg); }
}
// 머리의 값 요약은 접힘과 동시에 steps(1, end) 로 한 프레임에 컷 등장한다
@keyframes vz-sv-1 {
0%, 17% { opacity: 0; }
18%, 73% { opacity: 1; }
74%, 100% { opacity: 0; }
}
@keyframes vz-sv-2 {
0%, 51% { opacity: 0; }
52%, 84% { opacity: 1; }
85%, 100% { opacity: 0; }
}
@keyframes vz-sv-3 {
0%, 75% { opacity: 0; }
76%, 84% { opacity: 1; }
85%, 100% { opacity: 0; }
}
.vz.is-demo {
.vz__item[data-item="1"] .vz__body { animation-name: vz-rows-1; }
.vz__item[data-item="2"] .vz__body { animation-name: vz-rows-2; }
.vz__item[data-item="3"] .vz__body { animation-name: vz-rows-3; }
.vz__body {
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.vz__item[data-item="1"] .vz__inner { animation-name: vz-pad-1; }
.vz__item[data-item="2"] .vz__inner { animation-name: vz-pad-2; }
.vz__item[data-item="3"] .vz__inner { animation-name: vz-pad-3; }
.vz__inner {
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.vz__item[data-item="1"] .vz__caret { animation-name: vz-caret-1; }
.vz__item[data-item="2"] .vz__caret { animation-name: vz-caret-2; }
.vz__item[data-item="3"] .vz__caret { animation-name: vz-caret-3; }
.vz__caret {
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.vz__item[data-item="1"] .vz__sv { animation-name: vz-sv-1; }
.vz__item[data-item="2"] .vz__sv { animation-name: vz-sv-2; }
.vz__item[data-item="3"] .vz__sv { animation-name: vz-sv-3; }
.vz__sv {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.vz { padding: $sp-1; gap: $sp-1; }
.vz__h { padding: $sp-1; gap: $sp-1; font-size: 11px; }
.vz__num { width: 16px; height: 16px; }
.vz__sv { max-width: 88px; font-size: 10px; }
.vz__caret { width: 12px; height: 12px; }
.vz__inner { gap: $sp-1; padding: 0 $sp-1; }
.vz__item.is-open .vz__inner { padding: $sp-1; }
.vz__inp { padding: 0 $sp-1; font-size: 11px; }
.vz__next { padding: 0 $sp-2; font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.vz.is-demo .vz__body,
.vz.is-demo .vz__inner,
.vz.is-demo .vz__caret,
.vz.is-demo .vz__sv { animation: none !important; }
// 움직임만 끄고 판정 결과는 남는다 — 정적 규칙이 1단계 값 요약 + 2단계 펼침을 그대로 보여 준다
}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);
}
.vz {
box-sizing: border-box;
display: grid;
gap: 4px;
width: min(304px, 92vw);
padding: 8px;
border: 1px solid rgb(81.8039215686%, 67.4509803922%, 21.8039215686%);
border-radius: 16px;
background: #ffd23f;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
transition: border-color 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vz.is-all {
border-color: #2f6df6;
}
.vz__item {
border: 1px solid rgb(81.8039215686%, 67.4509803922%, 21.8039215686%);
border-radius: 8px;
background: rgb(92.7215686275%, 76.3921568627%, 23.5450980392%);
transition: border-color 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vz__item.is-open {
border-color: #2f6df6;
}
.vz__h {
display: grid;
grid-template-columns: auto 1fr auto auto;
gap: 8px;
align-items: center;
width: 100%;
margin: 0;
padding: 8px;
border: 0;
background: transparent;
color: inherit;
font-family: inherit;
font-size: 12px;
font-weight: 700;
line-height: 1.3;
text-align: left;
cursor: pointer;
}
.vz__h:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 1px;
}
.vz__num {
display: grid;
place-items: center;
width: 18px;
height: 18px;
border: 1px solid rgb(81.8039215686%, 67.4509803922%, 21.8039215686%);
border-radius: 50%;
font-size: 10px;
color: rgba(23, 20, 26, 0.72);
transition: background-color 300ms cubic-bezier(0.2, 0.8, 0.2, 1), border-color 300ms cubic-bezier(0.2, 0.8, 0.2, 1), color 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vz__item.is-open .vz__num {
border-color: #2f6df6;
color: #17141a;
}
.vz__item.is-done .vz__num {
border-color: #0a4fe8;
background: #0a4fe8;
color: #fff7e6;
}
.vz__t {
color: #17141a;
}
.vz__sv {
max-width: 120px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 11px;
font-weight: 400;
color: rgba(23, 20, 26, 0.72);
opacity: 0;
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vz__item.is-done .vz__sv {
opacity: 1;
}
.vz__item.is-open .vz__sv {
opacity: 0;
}
.vz__caret {
display: inline-flex;
width: 14px;
height: 14px;
color: rgba(23, 20, 26, 0.55);
transform: rotate(-90deg);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vz__item.is-open .vz__caret {
transform: rotate(0deg);
}
.vz__body {
display: grid;
grid-template-rows: 0fr;
visibility: hidden;
transition: grid-template-rows 300ms cubic-bezier(0.2, 0.8, 0.2, 1), visibility 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vz__item.is-open .vz__body {
grid-template-rows: 1fr;
visibility: visible;
}
.vz__inner {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
align-items: center;
min-height: 0;
overflow: hidden;
padding: 0 8px;
transition: padding 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vz__item.is-open .vz__inner {
padding: 8px;
}
.vz__inp {
box-sizing: border-box;
width: 100%;
min-width: 0;
margin: 0;
padding: 4px;
border: 1px solid rgb(81.8039215686%, 67.4509803922%, 21.8039215686%);
border-radius: 8px;
background: #ffd23f;
color: #17141a;
font-family: inherit;
font-size: 12px;
line-height: 1.3;
}
.vz__inp:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 1px;
}
.vz__next {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
cursor: pointer;
}
.vz__next:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 1px;
}
.vz.is-demo .vz__inp::placeholder {
color: transparent;
}
@keyframes vz-rows-1 {
0%, 6% {
grid-template-rows: 1fr;
visibility: visible;
}
16%, 74% {
grid-template-rows: 0fr;
visibility: hidden;
}
84%, 100% {
grid-template-rows: 1fr;
visibility: visible;
}
}
@keyframes vz-rows-2 {
0%, 16% {
grid-template-rows: 0fr;
visibility: hidden;
}
26%, 40% {
grid-template-rows: 1fr;
visibility: visible;
}
50%, 100% {
grid-template-rows: 0fr;
visibility: hidden;
}
}
@keyframes vz-rows-3 {
0%, 50% {
grid-template-rows: 0fr;
visibility: hidden;
}
60%, 72% {
grid-template-rows: 1fr;
visibility: visible;
}
82%, 100% {
grid-template-rows: 0fr;
visibility: hidden;
}
}
@keyframes vz-pad-1 {
0%, 6% {
padding: 8px 8px;
}
16%, 74% {
padding: 0 8px;
}
84%, 100% {
padding: 8px 8px;
}
}
@keyframes vz-pad-2 {
0%, 16% {
padding: 0 8px;
}
26%, 40% {
padding: 8px 8px;
}
50%, 100% {
padding: 0 8px;
}
}
@keyframes vz-pad-3 {
0%, 50% {
padding: 0 8px;
}
60%, 72% {
padding: 8px 8px;
}
82%, 100% {
padding: 0 8px;
}
}
@keyframes vz-caret-1 {
0%, 6% {
transform: rotate(0deg);
}
16%, 74% {
transform: rotate(-90deg);
}
84%, 100% {
transform: rotate(0deg);
}
}
@keyframes vz-caret-2 {
0%, 16% {
transform: rotate(-90deg);
}
26%, 40% {
transform: rotate(0deg);
}
50%, 100% {
transform: rotate(-90deg);
}
}
@keyframes vz-caret-3 {
0%, 50% {
transform: rotate(-90deg);
}
60%, 72% {
transform: rotate(0deg);
}
82%, 100% {
transform: rotate(-90deg);
}
}
@keyframes vz-sv-1 {
0%, 17% {
opacity: 0;
}
18%, 73% {
opacity: 1;
}
74%, 100% {
opacity: 0;
}
}
@keyframes vz-sv-2 {
0%, 51% {
opacity: 0;
}
52%, 84% {
opacity: 1;
}
85%, 100% {
opacity: 0;
}
}
@keyframes vz-sv-3 {
0%, 75% {
opacity: 0;
}
76%, 84% {
opacity: 1;
}
85%, 100% {
opacity: 0;
}
}
.vz.is-demo .vz__item[data-item="1"] .vz__body {
animation-name: vz-rows-1;
}
.vz.is-demo .vz__item[data-item="2"] .vz__body {
animation-name: vz-rows-2;
}
.vz.is-demo .vz__item[data-item="3"] .vz__body {
animation-name: vz-rows-3;
}
.vz.is-demo .vz__body {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.vz.is-demo .vz__item[data-item="1"] .vz__inner {
animation-name: vz-pad-1;
}
.vz.is-demo .vz__item[data-item="2"] .vz__inner {
animation-name: vz-pad-2;
}
.vz.is-demo .vz__item[data-item="3"] .vz__inner {
animation-name: vz-pad-3;
}
.vz.is-demo .vz__inner {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.vz.is-demo .vz__item[data-item="1"] .vz__caret {
animation-name: vz-caret-1;
}
.vz.is-demo .vz__item[data-item="2"] .vz__caret {
animation-name: vz-caret-2;
}
.vz.is-demo .vz__item[data-item="3"] .vz__caret {
animation-name: vz-caret-3;
}
.vz.is-demo .vz__caret {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.vz.is-demo .vz__item[data-item="1"] .vz__sv {
animation-name: vz-sv-1;
}
.vz.is-demo .vz__item[data-item="2"] .vz__sv {
animation-name: vz-sv-2;
}
.vz.is-demo .vz__item[data-item="3"] .vz__sv {
animation-name: vz-sv-3;
}
.vz.is-demo .vz__sv {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.vz {
padding: 4px;
gap: 4px;
}
.vz__h {
padding: 4px;
gap: 4px;
font-size: 11px;
}
.vz__num {
width: 16px;
height: 16px;
}
.vz__sv {
max-width: 88px;
font-size: 10px;
}
.vz__caret {
width: 12px;
height: 12px;
}
.vz__inner {
gap: 4px;
padding: 0 4px;
}
.vz__item.is-open .vz__inner {
padding: 4px;
}
.vz__inp {
padding: 0 4px;
font-size: 11px;
}
.vz__next {
padding: 0 8px;
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.vz.is-demo .vz__body,
.vz.is-demo .vz__inner,
.vz.is-demo .vz__caret,
.vz.is-demo .vz__sv {
animation: none !important;
}
}// 실제 동작: 머리 단추는 그 단계를 접고 펼치고, 안의 단추는 입력값을 읽어
// 머리에 한 줄 요약으로 남긴 뒤 次へ 단계를 펼쳐 그 칸에 초점을 준다.
var vz = document.getElementById('vz');
var items = [].slice.call(vz.querySelectorAll('.vz__item'));
function setOpen(item, open) {
vz.classList.remove('is-demo');
item.classList.toggle('is-open', open);
item.querySelector('.vz__h').setAttribute('aria-expanded', open ? 'true' : 'false');
}
items.forEach(function (item, i) {
var inp = item.querySelector('.vz__inp');
item.querySelector('.vz__h').addEventListener('click', function () {
setOpen(item, !item.classList.contains('is-open'));
});
item.querySelector('.vz__next').addEventListener('click', function () {
item.querySelector('.vz__sv').textContent = inp.value.trim() || '—';
item.classList.add('is-done');
setOpen(item, false);
var nxt = items[i + 1];
if (nxt) {
setOpen(nxt, true);
nxt.querySelector('.vz__inp').focus({ preventScroll: true });
} else {
vz.classList.add('is-all');
item.querySelector('.vz__h').focus({ preventScroll: true });
}
});
inp.addEventListener('input', function () { vz.classList.remove('is-demo'); });
inp.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); item.querySelector('.vz__next').click(); }
});
});import { useState, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./AccordionVerticalSteps.module.scss";
export type WizardStep = { title: string; value: string };
export type AccordionVerticalStepsProps = {
steps?: WizardStep[];
nextLabel?: string;
finishLabel?: string;
groupLabel?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type FocusEl = { focus(opt: { preventScroll: boolean }): void };
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
type InpEvt = { target: { value: string } };
const DEFAULT_STEPS: WizardStep[] = [
{ title: "Recipient", value: "Hong Gildong" },
{ title: "Address", value: "12-34 Yeoksam-dong, Gangnam-gu, Seoul" },
{ title: "Phone", value: "010-2345-6789" },
];
function Caret() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="m208 96l-80 80l-80-80Z" opacity=".2" />
<path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z" />
</g>
</svg>
);
}
export default function AccordionVerticalSteps({
steps = DEFAULT_STEPS,
nextLabel = "Next",
finishLabel = "Done",
groupLabel = "Delivery details in three steps",
demo = true,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: AccordionVerticalStepsProps) {
// 정적 상태: 1단계 끝남(접혀 값 요약) · 2단계 펼침 · 3단계 대기
const [open, setOpen] = useState<number | null>(1);
const [vals, setVals] = useState<string[]>(steps.map((s) => s.value));
const [done, setDone] = useState<boolean[]>(steps.map((_, i) => i === 0));
const [all, setAll] = useState(false);
const [live, setLive] = useState(demo);
const boxes = useRef<(FocusEl | null)[]>([]);
// ambient useRef 는 T | null 을 돌려주므로 로컬 별칭으로 한 번만 좁혀 쓴다
const boxesBox: Box<(FocusEl | null)[]> = boxes as Box<(FocusEl | null)[]>;
const heads = useRef<(FocusEl | null)[]>([]);
const headsBox: Box<(FocusEl | null)[]> = heads as Box<(FocusEl | null)[]>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function toggle(i: number) {
setLive(false);
setOpen((prev) => (prev === i ? null : i));
}
// 값 읽어 머리에 한 줄로 남기고 다음 단계를 펼쳐 그 칸에 초점을 준다
function advance(i: number) {
setLive(false);
setDone((prev) => prev.map((d, n) => (n === i ? true : d)));
const boxesArr = boxes.current;
const headsArr = heads.current;
if (i + 1 < steps.length) {
setOpen(i + 1);
const el = boxesArr ? boxesArr[i + 1] : null;
if (el) el.focus({ preventScroll: true });
} else {
setOpen(null);
setAll(true);
const h = headsArr ? headsArr[i] : null;
if (h) h.focus({ preventScroll: true });
}
}
const cls = [styles.vz, live ? styles.isDemo : "", all ? styles.isAll : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
{steps.map((s, i) => {
const isOpen = open === i;
return (
<div
key={s.title}
className={[styles.item, done[i] ? styles.isDone : "", isOpen ? styles.isOpen : ""].filter(Boolean).join(" ")}
data-item={i + 1}
>
<button
className={styles.h}
type="button"
id={`vz-h-${i}`}
ref={(el: FocusEl | null) => { const arr = heads.current; if (arr) arr[i] = el; }}
aria-expanded={isOpen}
aria-controls={`vz-p-${i}`}
onClick={() => toggle(i)}
>
<span className={styles.num} aria-hidden="true">{i + 1}</span>
<span className={styles.t}>{s.title}</span>
<span className={styles.sv}>{vals[i] || "—"}</span>
<span className={styles.caret} aria-hidden="true"><Caret /></span>
</button>
<div className={styles.body} id={`vz-p-${i}`} role="region" aria-labelledby={`vz-h-${i}`}>
<div className={styles.inner}>
<input
className={styles.inp}
ref={(el: FocusEl | null) => { const arr = boxes.current; if (arr) arr[i] = el; }}
value={vals[i]}
aria-label={s.title}
placeholder={s.value}
onChange={(e: InpEvt) => {
setLive(false);
setVals((prev) => prev.map((v, n) => (n === i ? e.target.value : v)));
}}
onKeyDown={(e: KeyEvt) => {
if (e.key === "Enter") { e.preventDefault(); advance(i); }
}}
/>
<button className={styles.next} type="button" onClick={() => advance(i)}>
{i + 1 < steps.length ? nextLabel : finishLabel}
</button>
</div>
</div>
</div>
);
})}
</section>
);
}06 · 終わったステップが一行に畳まれる縦型ウィザード — 計測 累積面積 29.447% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
0fr でも縦パディングは残るので、本体に min-height: 0 と overflow: hidden の内側ラッパーを置くのがこのパターンの急所です。
.vz__body {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 300ms cubic-bezier(.2, .8, .2, 1),
visibility 300ms;
}
.vz__item.is-open .vz__body { grid-template-rows: 1fr; }
.vz__inner {
min-height: 0;
overflow: hidden;
}
07送信前にすべて広げて見る確認画面
何が見えるか
最後の画面は、前のステップで受け取った値をグループごとに書き直して並べます。各グループの鉛筆ボタンは自分のステップ番号を覚えていて、押されたグループだけ一瞬浮き上がり、値が本物の入力欄に変わります。

<main class="stage">
<section class="rv is-demo" id="rv" aria-label="送信前の最終確認">
<p class="rv__h">最終確認</p>
<div class="rv__g" data-g="1">
<div class="rv__top">
<p class="rv__name">配送先</p>
<button class="rv__pen" type="button" data-step="1" aria-label="配送先を修正" aria-pressed="false">
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M221.66 90.34L192 120l-56-56l29.66-29.66a8 8 0 0 1 11.31 0L221.66 79a8 8 0 0 1 0 11.34" opacity=".2"/><path d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z"/></g></svg>
</button>
</div>
<dl class="rv__kv">
<div class="rv__row">
<dt class="rv__k">宛名</dt>
<dd class="rv__dd" data-label="宛名"><span class="rv__val">ホン・ギルドン</span></dd>
</div>
<div class="rv__row">
<dt class="rv__k">住所</dt>
<dd class="rv__dd" data-label="住所"><span class="rv__val">ソウル市江南区駅三洞 12-34</span></dd>
</div>
</dl>
</div>
<div class="rv__g" data-g="2">
<div class="rv__top">
<p class="rv__name">電話番号</p>
<button class="rv__pen" type="button" data-step="2" aria-label="連絡先を修正" aria-pressed="false">
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M221.66 90.34L192 120l-56-56l29.66-29.66a8 8 0 0 1 11.31 0L221.66 79a8 8 0 0 1 0 11.34" opacity=".2"/><path d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z"/></g></svg>
</button>
</div>
<dl class="rv__kv">
<div class="rv__row">
<dt class="rv__k">電話</dt>
<dd class="rv__dd" data-label="電話"><span class="rv__val">090-2345-6789</span></dd>
</div>
<div class="rv__row">
<dt class="rv__k">メール</dt>
<dd class="rv__dd" data-label="メール"><span class="rv__val">[email protected]</span></dd>
</div>
</dl>
</div>
<div class="rv__g" data-g="3">
<div class="rv__top">
<p class="rv__name">支払い方法</p>
<button class="rv__pen" type="button" data-step="3" aria-label="支払い方法を修正" aria-pressed="false">
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M221.66 90.34L192 120l-56-56l29.66-29.66a8 8 0 0 1 11.31 0L221.66 79a8 8 0 0 1 0 11.34" opacity=".2"/><path d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z"/></g></svg>
</button>
</div>
<dl class="rv__kv">
<div class="rv__row">
<dt class="rv__k">決済カード</dt>
<dd class="rv__dd" data-label="決済カード"><span class="rv__val">個人カード · 4444</span></dd>
</div>
<div class="rv__row">
<dt class="rv__k">分割</dt>
<dd class="rv__dd" data-label="分割"><span class="rv__val">一括払い</span></dd>
</div>
</dl>
</div>
</section>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-orange);
$line: color.mix($subject-cream, $stage-orange, 24%);
$soft: color.mix($subject-cream, $stage-orange, 10%);
$dim: rgba(255, 247, 230, .72);
$muted: rgba(255, 247, 230, .5);
.rv {
box-sizing: border-box;
display: grid;
gap: $sp-1;
width: min(320px, 92vw);
padding: $sp-2;
border: 1px solid $line;
border-radius: $r-card;
background: $stage-orange;
color: $subject-cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.rv__h { margin: 0; font-size: 11px; font-weight: 700; letter-spacing: .04em; color: $subject-ink; }
// ── 값 묶음 하나 ────────────────────────────────
.rv__g {
box-sizing: border-box;
padding: $sp-2;
border: 1px solid $line;
border-radius: $r-sm;
background: transparent;
transition: border-color $duration $easing, background-color $duration $easing;
}
// 편집 상태 — 눌린 묶음만 살짝 들렀다 가라앉는다(scale 한 번 재생)
.rv__g.is-edit {
border-color: $color;
background: $soft;
animation-name: rv-lift;
animation-duration: 600ms;
animation-timing-function: $easing;
animation-iteration-count: 1;
}
@keyframes rv-lift {
0% { transform: scale(1); }
40% { transform: scale(1.04); }
100% { transform: scale(1); }
}
.rv__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
margin: 0 0 $sp-1;
}
.rv__name { margin: 0; font-size: 11px; font-weight: 700; color: $subject-ink; }
.rv__pen {
display: inline-grid;
place-items: center;
width: 18px;
height: 18px;
margin: 0;
padding: 0;
border: 1px solid $line;
border-radius: 50%;
background: transparent;
color: $dim;
cursor: pointer;
transition: background-color $duration $easing, border-color $duration $easing, color $duration $easing;
}
.rv__pen svg { width: 12px; height: 12px; }
.rv__pen:hover { border-color: $color; color: $subject-cream; }
.rv__pen:focus-visible { outline: 2px solid $color; outline-offset: 1px; }
.rv__g.is-edit .rv__pen { background: $color; border-color: $color; color: $stage-orange; }
.rv__kv { display: grid; margin: 0; }
.rv__row {
display: grid;
grid-template-columns: 64px 1fr;
gap: $sp-2;
align-items: baseline;
}
.rv__k { font-size: 11px; color: $subject-ink; }
.rv__dd {
margin: 0;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 11px;
color: $subject-cream;
}
// 정적 상태(사람이 조작하는 길) — 검토 목록은 값이 적혀 있는 채로 산다
.rv__val {
color: $subject-ink;
border-radius: $r-xs;
transition: background-color $duration $easing, box-shadow $duration $easing;
}
// 편집으로 진짜 생기는 칸 — 값이 input 으로 바뀐다
.rv__inp {
box-sizing: border-box;
width: 100%;
min-width: 0;
margin: 0;
padding: 0 $sp-1;
border: 0;
border-bottom: 1px solid $color;
border-radius: $r-xs $r-xs 0 0;
background: rgba(255, 247, 230, .14);
color: $subject-ink;
font-family: inherit;
font-size: 11px;
line-height: 1.4;
}
.rv__inp:focus-visible { outline: 2px solid $color; outline-offset: 1px; }
// ── 격자 미리보기 전용 2초 루프 ──────────────────
// 묶음이 하나씩 scale 로 들렀다 놓이고, 편집 상태 전환은 steps(1, end) 로 한 프레임에 컷.
// 검토 목록(주인공)은 루프 안에서 지우지 않는다.
@keyframes rv-lift-1 {
0%, 4% { transform: scale(1); }
10% { transform: scale(1.04); }
16%, 100% { transform: scale(1); }
}
@keyframes rv-lift-2 {
0%, 32% { transform: scale(1); }
38% { transform: scale(1.04); }
44%, 100% { transform: scale(1); }
}
@keyframes rv-lift-3 {
0%, 60% { transform: scale(1); }
66% { transform: scale(1.04); }
72%, 100% { transform: scale(1); }
}
// 편집 상태는 한 번 켜지면 루프 끝까지 유지되고, 처음으로 돌아가는 컷은
// 애니메이션 경계(100% → 0%) 자체다 — 세 묶음이 한꺼번에 꺼지는 프레임이 poster 로 뽑히는 일을 막는다
@keyframes rv-edit-1 {
0%, 18% { border-color: $line; background: transparent; }
19%, 100% { border-color: $color; background: $soft; }
}
@keyframes rv-edit-2 {
0%, 48% { border-color: $line; background: transparent; }
49%, 100% { border-color: $color; background: $soft; }
}
@keyframes rv-edit-3 {
0%, 78% { border-color: $line; background: transparent; }
79%, 100% { border-color: $color; background: $soft; }
}
@keyframes rv-pen-1 {
0%, 18% { background: transparent; color: $dim; }
19%, 100% { background: $color; color: $stage-orange; }
}
@keyframes rv-pen-2 {
0%, 48% { background: transparent; color: $dim; }
49%, 100% { background: $color; color: $stage-orange; }
}
@keyframes rv-pen-3 {
0%, 78% { background: transparent; color: $dim; }
79%, 100% { background: $color; color: $stage-orange; }
}
@keyframes rv-field-1 {
0%, 18% { background: transparent; box-shadow: none; }
19%, 100% { background: rgba(255, 247, 230, .14); box-shadow: 0 1px 0 $color; }
}
@keyframes rv-field-2 {
0%, 48% { background: transparent; box-shadow: none; }
49%, 100% { background: rgba(255, 247, 230, .14); box-shadow: 0 1px 0 $color; }
}
@keyframes rv-field-3 {
0%, 78% { background: transparent; box-shadow: none; }
79%, 100% { background: rgba(255, 247, 230, .14); box-shadow: 0 1px 0 $color; }
}
.rv.is-demo {
.rv__g[data-g="1"] { animation-name: rv-lift-1, rv-edit-1; }
.rv__g[data-g="2"] { animation-name: rv-lift-2, rv-edit-2; }
.rv__g[data-g="3"] { animation-name: rv-lift-3, rv-edit-3; }
.rv__g {
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: $easing, steps(1, end);
animation-iteration-count: infinite, infinite;
}
.rv__g[data-g="1"] .rv__pen { animation-name: rv-pen-1; }
.rv__g[data-g="2"] .rv__pen { animation-name: rv-pen-2; }
.rv__g[data-g="3"] .rv__pen { animation-name: rv-pen-3; }
.rv__pen {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rv__g[data-g="1"] .rv__val { animation-name: rv-field-1; }
.rv__g[data-g="2"] .rv__val { animation-name: rv-field-2; }
.rv__g[data-g="3"] .rv__val { animation-name: rv-field-3; }
.rv__val {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
}
// 좁은 폭에서는 각 묶음의 첫 값만 보인다 — 320px 세로 예산(174px) 안에 들어가게
@media (max-width: 340px) {
.rv { padding: $sp-2; gap: $sp-1; }
.rv__g { padding: $sp-1; }
.rv__top { margin-bottom: $sp-1; }
.rv__pen { width: 16px; height: 16px; }
.rv__pen svg { width: 10px; height: 10px; }
.rv__row { grid-template-columns: 52px 1fr; gap: $sp-1; }
.rv__name, .rv__k, .rv__dd, .rv__inp { font-size: 10px; }
.rv__kv .rv__row:nth-child(2) { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.rv.is-demo .rv__g,
.rv.is-demo .rv__pen,
.rv.is-demo .rv__val { animation: none !important; transform: none !important; }
// 움직임만 끄고 판정 결과는 남긴다 — 첫 묶음이 편집 상태인 장을 보여 준다
.rv.is-demo .rv__g[data-g="1"] { border-color: $color !important; background: $soft !important; }
.rv.is-demo .rv__g[data-g="1"] .rv__pen { background: $color !important; border-color: $color !important; color: $stage-orange !important; }
.rv.is-demo .rv__g[data-g="1"] .rv__val { background: rgba(255, 247, 230, .14) !important; box-shadow: 0 1px 0 $color !important; }
}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);
}
.rv {
box-sizing: border-box;
display: grid;
gap: 4px;
width: min(320px, 92vw);
padding: 8px;
border: 1px solid rgb(100%, 46.1960784314%, 30.8862745098%);
border-radius: 16px;
background: #ff4d1f;
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.rv__h {
margin: 0;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
color: #17141a;
}
.rv__g {
box-sizing: border-box;
padding: 8px;
border: 1px solid rgb(100%, 46.1960784314%, 30.8862745098%);
border-radius: 8px;
background: transparent;
transition: border-color 300ms cubic-bezier(0.2, 0.8, 0.2, 1), background-color 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rv__g.is-edit {
border-color: #fff7e6;
background: rgb(100%, 36.862745098%, 19.9607843137%);
animation-name: rv-lift;
animation-duration: 600ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: 1;
}
@keyframes rv-lift {
0% {
transform: scale(1);
}
40% {
transform: scale(1.04);
}
100% {
transform: scale(1);
}
}
.rv__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: 0 0 4px;
}
.rv__name {
margin: 0;
font-size: 11px;
font-weight: 700;
color: #17141a;
}
.rv__pen {
display: inline-grid;
place-items: center;
width: 18px;
height: 18px;
margin: 0;
padding: 0;
border: 1px solid rgb(100%, 46.1960784314%, 30.8862745098%);
border-radius: 50%;
background: transparent;
color: rgba(255, 247, 230, 0.72);
cursor: pointer;
transition: background-color 300ms cubic-bezier(0.2, 0.8, 0.2, 1), border-color 300ms cubic-bezier(0.2, 0.8, 0.2, 1), color 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rv__pen svg {
width: 12px;
height: 12px;
}
.rv__pen:hover {
border-color: #fff7e6;
color: #fff7e6;
}
.rv__pen:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 1px;
}
.rv__g.is-edit .rv__pen {
background: #fff7e6;
border-color: #fff7e6;
color: #ff4d1f;
}
.rv__kv {
display: grid;
margin: 0;
}
.rv__row {
display: grid;
grid-template-columns: 64px 1fr;
gap: 8px;
align-items: baseline;
}
.rv__k {
font-size: 11px;
color: #17141a;
}
.rv__dd {
margin: 0;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 11px;
color: #fff7e6;
}
.rv__val {
color: #17141a;
border-radius: 4px;
transition: background-color 300ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rv__inp {
box-sizing: border-box;
width: 100%;
min-width: 0;
margin: 0;
padding: 0 4px;
border: 0;
border-bottom: 1px solid #fff7e6;
border-radius: 4px 4px 0 0;
background: rgba(255, 247, 230, 0.14);
color: #17141a;
font-family: inherit;
font-size: 11px;
line-height: 1.4;
}
.rv__inp:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 1px;
}
@keyframes rv-lift-1 {
0%, 4% {
transform: scale(1);
}
10% {
transform: scale(1.04);
}
16%, 100% {
transform: scale(1);
}
}
@keyframes rv-lift-2 {
0%, 32% {
transform: scale(1);
}
38% {
transform: scale(1.04);
}
44%, 100% {
transform: scale(1);
}
}
@keyframes rv-lift-3 {
0%, 60% {
transform: scale(1);
}
66% {
transform: scale(1.04);
}
72%, 100% {
transform: scale(1);
}
}
@keyframes rv-edit-1 {
0%, 18% {
border-color: rgb(100%, 46.1960784314%, 30.8862745098%);
background: transparent;
}
19%, 100% {
border-color: #fff7e6;
background: rgb(100%, 36.862745098%, 19.9607843137%);
}
}
@keyframes rv-edit-2 {
0%, 48% {
border-color: rgb(100%, 46.1960784314%, 30.8862745098%);
background: transparent;
}
49%, 100% {
border-color: #fff7e6;
background: rgb(100%, 36.862745098%, 19.9607843137%);
}
}
@keyframes rv-edit-3 {
0%, 78% {
border-color: rgb(100%, 46.1960784314%, 30.8862745098%);
background: transparent;
}
79%, 100% {
border-color: #fff7e6;
background: rgb(100%, 36.862745098%, 19.9607843137%);
}
}
@keyframes rv-pen-1 {
0%, 18% {
background: transparent;
color: rgba(255, 247, 230, 0.72);
}
19%, 100% {
background: #fff7e6;
color: #ff4d1f;
}
}
@keyframes rv-pen-2 {
0%, 48% {
background: transparent;
color: rgba(255, 247, 230, 0.72);
}
49%, 100% {
background: #fff7e6;
color: #ff4d1f;
}
}
@keyframes rv-pen-3 {
0%, 78% {
background: transparent;
color: rgba(255, 247, 230, 0.72);
}
79%, 100% {
background: #fff7e6;
color: #ff4d1f;
}
}
@keyframes rv-field-1 {
0%, 18% {
background: transparent;
box-shadow: none;
}
19%, 100% {
background: rgba(255, 247, 230, 0.14);
box-shadow: 0 1px 0 #fff7e6;
}
}
@keyframes rv-field-2 {
0%, 48% {
background: transparent;
box-shadow: none;
}
49%, 100% {
background: rgba(255, 247, 230, 0.14);
box-shadow: 0 1px 0 #fff7e6;
}
}
@keyframes rv-field-3 {
0%, 78% {
background: transparent;
box-shadow: none;
}
79%, 100% {
background: rgba(255, 247, 230, 0.14);
box-shadow: 0 1px 0 #fff7e6;
}
}
.rv.is-demo .rv__g[data-g="1"] {
animation-name: rv-lift-1, rv-edit-1;
}
.rv.is-demo .rv__g[data-g="2"] {
animation-name: rv-lift-2, rv-edit-2;
}
.rv.is-demo .rv__g[data-g="3"] {
animation-name: rv-lift-3, rv-edit-3;
}
.rv.is-demo .rv__g {
animation-duration: 2s, 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1), steps(1, end);
animation-iteration-count: infinite, infinite;
}
.rv.is-demo .rv__g[data-g="1"] .rv__pen {
animation-name: rv-pen-1;
}
.rv.is-demo .rv__g[data-g="2"] .rv__pen {
animation-name: rv-pen-2;
}
.rv.is-demo .rv__g[data-g="3"] .rv__pen {
animation-name: rv-pen-3;
}
.rv.is-demo .rv__pen {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rv.is-demo .rv__g[data-g="1"] .rv__val {
animation-name: rv-field-1;
}
.rv.is-demo .rv__g[data-g="2"] .rv__val {
animation-name: rv-field-2;
}
.rv.is-demo .rv__g[data-g="3"] .rv__val {
animation-name: rv-field-3;
}
.rv.is-demo .rv__val {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.rv {
padding: 8px;
gap: 4px;
}
.rv__g {
padding: 4px;
}
.rv__top {
margin-bottom: 4px;
}
.rv__pen {
width: 16px;
height: 16px;
}
.rv__pen svg {
width: 10px;
height: 10px;
}
.rv__row {
grid-template-columns: 52px 1fr;
gap: 4px;
}
.rv__name, .rv__k, .rv__dd, .rv__inp {
font-size: 10px;
}
.rv__kv .rv__row:nth-child(2) {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.rv.is-demo .rv__g,
.rv.is-demo .rv__pen,
.rv.is-demo .rv__val {
animation: none !important;
transform: none !important;
}
.rv.is-demo .rv__g[data-g="1"] {
border-color: #fff7e6 !important;
background: rgb(100%, 36.862745098%, 19.9607843137%) !important;
}
.rv.is-demo .rv__g[data-g="1"] .rv__pen {
background: #fff7e6 !important;
border-color: #fff7e6 !important;
color: #ff4d1f !important;
}
.rv.is-demo .rv__g[data-g="1"] .rv__val {
background: rgba(255, 247, 230, 0.14) !important;
box-shadow: 0 1px 0 #fff7e6 !important;
}
}// 실제 동작: 연필 단추를 누른 묶음의 값이 진짜 input 으로 바뀌고,
// 다시 누르면 고친 값을 텍스트로 되돌려 적는다(화면만 바꾸는 흉내가 아니다).
var rv = document.getElementById('rv');
var groups = [].slice.call(rv.querySelectorAll('.rv__g'));
groups.forEach(function (g) {
var pen = g.querySelector('.rv__pen');
pen.addEventListener('click', function () {
rv.classList.remove('is-demo');
var editing = g.classList.toggle('is-edit');
pen.setAttribute('aria-pressed', editing ? 'true' : 'false');
g.querySelectorAll('.rv__dd').forEach(function (dd) {
var old = dd.querySelector('input');
if (editing && !old) {
var inp = document.createElement('input');
inp.className = 'rv__inp';
inp.value = dd.textContent.trim();
inp.setAttribute('aria-label', dd.getAttribute('data-label'));
dd.textContent = '';
dd.appendChild(inp);
} else if (!editing && old) {
dd.textContent = old.value.trim() || '—';
}
});
var first = g.querySelector('input');
if (editing && first) first.focus({ preventScroll: true });
});
g.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && g.classList.contains('is-edit')) { e.preventDefault(); pen.click(); }
});
});import { useState, useRef, useEffect } from "react";
import type { CSSProperties } from "react";
import styles from "./FinalReviewEdit.module.scss";
export type ReviewField = { label: string; value: string };
export type ReviewGroup = {
name: string;
editLabel: string;
fields: ReviewField[];
};
export type FinalReviewEditProps = {
heading?: string;
groups?: ReviewGroup[];
groupLabel?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type FocusEl = { focus(opt: { preventScroll: boolean }): void };
type KeyEvt = { key: string; preventDefault(): void };
type InpEvt = { target: { value: string } };
const DEFAULT_GROUPS: ReviewGroup[] = [
{
name: "Delivery",
editLabel: "Edit the delivery address",
fields: [
{ label: "Recipient", value: "Hong Gildong" },
{ label: "Address", value: "12-34 Yeoksam-dong, Gangnam-gu, Seoul" },
],
},
{
name: "Contact",
editLabel: "Edit the contact details",
fields: [
{ label: "Phone", value: "010-2345-6789" },
{ label: "Email", value: "[email protected]" },
],
},
{
name: "Payment",
editLabel: "Edit the payment method",
fields: [
{ label: "Card", value: "Personal card · 4444" },
{ label: "Plan", value: "Pay in full" },
],
},
];
function Pencil() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M221.66 90.34L192 120l-56-56l29.66-29.66a8 8 0 0 1 11.31 0L221.66 79a8 8 0 0 1 0 11.34" opacity=".2" />
<path d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z" />
</g>
</svg>
);
}
export default function FinalReviewEdit({
heading = "Last check",
groups = DEFAULT_GROUPS,
groupLabel = "Final review before sending",
demo = true,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: FinalReviewEditProps) {
const [editing, setEditing] = useState<number | null>(null);
const [values, setValues] = useState<string[][]>(groups.map((g) => g.fields.map((f) => f.value)));
const [live, setLive] = useState(demo);
const firstBox = useRef<FocusEl | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 편집에 들어가면 첫 칸에 초점 — iframe 안이라 preventScroll 이 필수
useEffect(() => {
if (editing !== null) {
const el = firstBox.current;
if (el) el.focus({ preventScroll: true });
}
}, [editing]);
// 연필: 눌린 묶음의 값이 진짜 input 으로 바뀌고, 다시 누르면 고친 값이 텍스트로 남는다
function press(gi: number) {
setLive(false);
setEditing((prev) => (prev === gi ? null : gi));
}
const cls = [styles.rv, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<p className={styles.h}>{heading}</p>
{groups.map((g, gi) => {
const isEdit = editing === gi;
return (
<div key={g.name} className={[styles.g, isEdit ? styles.isEdit : ""].filter(Boolean).join(" ")} data-g={gi + 1}>
<div className={styles.top}>
<p className={styles.name}>{g.name}</p>
<button
className={styles.pen}
type="button"
data-step={gi + 1}
aria-label={g.editLabel}
aria-pressed={isEdit}
onClick={() => press(gi)}
>
<Pencil />
</button>
</div>
<dl className={styles.kv}>
{g.fields.map((f, fi) => (
<div key={f.label} className={styles.row}>
<dt className={styles.k}>{f.label}</dt>
<dd className={styles.dd}>
{isEdit ? (
<input
className={styles.inp}
value={values[gi][fi]}
aria-label={f.label}
ref={fi === 0 ? (el: FocusEl | null) => { firstBox.current = el; } : undefined}
onChange={(e: InpEvt) => {
setValues((prev) => prev.map((row, n) => (n === gi ? row.map((v, m) => (m === fi ? e.target.value : v)) : row)));
}}
onKeyDown={(e: KeyEvt) => {
if (e.key === "Enter") { e.preventDefault(); setEditing(null); }
}}
/>
) : (
<span className={styles.val}>{values[gi][fi] || "—"}</span>
)}
</dd>
</div>
))}
</dl>
</div>
);
})}
</section>
);
}07 · 送信前にすべて広げて見る確認画面 — 計測 累積面積 45.66% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
もう一度押すと、直した値がテキストへ戻って書き込まれます。
g.querySelectorAll('.rv__dd').forEach(function (dd) {
var old = dd.querySelector('input');
if (editing && !old) {
var inp = document.createElement('input');
inp.value = dd.textContent.trim();
dd.textContent = '';
dd.appendChild(inp);
} else if (!editing && old) {
dd.textContent = old.value.trim() || '—';
}
});
08空欄が残ったステップへ送り返す送信
画面で起きること
大切なのは空欄のあるステップの名前ではなく、その場所へ返すことです。送信を押すとステップを先頭から走査し、value が空の最初のステップへ戻ります。そのステップ表示には感嘆符の警告アイコンが付き、欄には aria-invalid が付き、ステップ見出しにはフォーカスが移ります。

<main class="stage">
<section class="ir is-demo" id="ir" aria-label="申込のステップ">
<ol class="ir__steps" aria-label="ステップ表示">
<li class="ir__step" data-step="1">
<span class="ir__bar" aria-hidden="true"></span>
<span class="ir__cap"><span class="ir__n">1</span><span class="ir__warn" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88m-8-80V80a8 8 0 0 1 16 0v56a8 8 0 0 1-16 0m20 36a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/></g></svg></span></span>
</li>
<li class="ir__step" data-step="2">
<span class="ir__bar" aria-hidden="true"></span>
<span class="ir__cap"><span class="ir__n">2</span><span class="ir__warn" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88m-8-80V80a8 8 0 0 1 16 0v56a8 8 0 0 1-16 0m20 36a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/></g></svg></span></span>
</li>
<li class="ir__step" data-step="3">
<span class="ir__bar" aria-hidden="true"></span>
<span class="ir__cap"><span class="ir__n">3</span><span class="ir__warn" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88m-8-80V80a8 8 0 0 1 16 0v56a8 8 0 0 1-16 0m20 36a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/></g></svg></span></span>
</li>
<li class="ir__step" data-step="4">
<span class="ir__bar" aria-hidden="true"></span>
<span class="ir__cap"><span class="ir__n">4</span><span class="ir__warn" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88m-8-80V80a8 8 0 0 1 16 0v56a8 8 0 0 1-16 0m20 36a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/></g></svg></span></span>
</li>
</ol>
<div class="ir__view">
<div class="ir__track" id="ir-track">
<div class="ir__panel">
<h3 class="ir__title" tabindex="-1">ステップ1 · 名前</h3>
<div class="ir__field">
<label class="ir__lab" for="ir-f1">名前</label>
<input class="ir__in" id="ir-f1" type="text" value="キム・ドユン" aria-invalid="false" autocomplete="off">
</div>
</div>
<div class="ir__panel">
<h3 class="ir__title" tabindex="-1">ステップ2 · 連絡先</h3>
<div class="ir__field">
<label class="ir__lab" for="ir-f2">携帯</label>
<input class="ir__in" id="ir-f2" type="text" value="090-2345-6789" aria-invalid="false" autocomplete="off">
</div>
</div>
<div class="ir__panel">
<h3 class="ir__title" tabindex="-1">ステップ3 · 配送先</h3>
<div class="ir__field">
<label class="ir__lab" for="ir-f3">住所</label>
<input class="ir__in" id="ir-f3" type="text" value="" placeholder="町名・番地" aria-invalid="false" autocomplete="off">
</div>
</div>
<div class="ir__panel">
<h3 class="ir__title" tabindex="-1">ステップ4 · 要望</h3>
<div class="ir__field">
<label class="ir__lab" for="ir-f4">要望</label>
<input class="ir__in" id="ir-f4" type="text" value="不在時は管理室へ" aria-invalid="false" autocomplete="off">
</div>
</div>
</div>
</div>
<div class="ir__foot">
<p class="ir__msg">
<span class="ir__msg-a">空欄が無ければ送信されます</span>
<span class="ir__msg-b">空欄が残るステップへ戻りました</span>
</p>
<button class="ir__submit" type="button" id="ir-submit">
<span class="ir__send" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="m223.69 42.18l-58.22 192a8 8 0 0 1-14.92 1.25L108 148l-87.42-42.55a8 8 0 0 1 1.25-14.92l192-58.22a8 8 0 0 1 9.86 9.87" opacity=".2"/><path d="M227.32 28.68a16 16 0 0 0-15.66-4.08h-.15L19.57 82.84a16 16 0 0 0-2.49 29.8L102 154l41.3 84.87a15.86 15.86 0 0 0 14.44 9.13q.69 0 1.38-.06a15.88 15.88 0 0 0 14-11.51l58.2-191.94v-.15a16 16 0 0 0-4-15.66m-69.49 203.17l-.05.14v-.07l-40.06-82.3l48-48a8 8 0 0 0-11.31-11.31l-48 48l-82.33-40.06h-.07h.14L216 40Z"/></g></svg></span>
<span class="ir__submit-t">送信</span>
</button>
</div>
</section>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
$line: color.mix($subject-ink, $stage-paper, 14%);
$soft: color.mix($subject-ink, $stage-paper, 5%);
$muted: rgba($subject-ink, .72);
// 트랙을 옮기는 단계 번호. 애니메이션·전이에 쓰이므로 형을 등록해 둔다
@property --i {
syntax: "<number>";
inherits: true;
initial-value: 3;
}
.ir {
--i: 3;
box-sizing: border-box;
width: min(320px, 92vw);
padding: $sp-2 $sp-3;
border: 1px solid $line;
border-radius: $r-card;
background: $stage-paper;
box-shadow: 0 2px 8px rgba(23, 20, 26, .07);
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
// ── 단계 표시 네 칸 ───────────────────────────────
.ir__steps {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: $sp-1;
margin: 0;
padding: 0;
list-style: none;
}
.ir__step {
display: grid;
gap: $sp-1;
}
.ir__bar {
height: 4px;
border-radius: $r-pill;
background: $line;
}
.ir__cap {
display: flex;
align-items: center;
gap: $sp-1;
color: $muted;
font-size: 10px;
font-weight: 700;
line-height: 1.3;
}
.ir__warn {
display: inline-flex;
width: 11px;
height: 11px;
color: $error;
opacity: 0;
transform: scale(.4);
}
// 정적(사람이 조작하는) 경로 — .is-demo 를 떼도 판정 결과가 보여야 한다
.ir__step.is-now .ir__bar { background: $color; }
.ir__step.is-now .ir__cap { color: $subject-ink; }
.ir__step.is-bad .ir__bar { background: $error; }
.ir__step.is-bad .ir__cap { color: $error; }
.ir__step.is-bad .ir__warn { opacity: 1; transform: scale(1); }
// ── 단계 하나가 통째로 미끄러지는 트랙 ──────────────
.ir__view {
overflow: hidden;
height: 44px;
margin-top: $sp-2;
}
.ir__track {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 100%;
width: 100%;
transform: translateX(calc(var(--i) * -100%));
transition: transform $duration $easing;
}
.ir__panel { min-width: 0; }
// 초점 테두리가 칸 전체 폭으로 번지지 않게 글자만큼만 차지한다
.ir__title {
display: inline-block;
margin: 0 0 $sp-1;
padding: 0 $sp-1;
outline: 2px solid transparent;
outline-offset: -2px;
color: $muted;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
}
// tabindex="-1" 헤딩은 :focus-visible 이 안 붙는 경우가 있어 :focus 로 둔다
.ir__title:focus { outline-color: $color; }
.ir__field {
display: grid;
grid-template-columns: 56px 1fr;
align-items: center;
gap: $sp-2;
}
.ir__lab {
color: $muted;
font-size: 11px;
line-height: 1.3;
text-align: right;
}
.ir__in {
box-sizing: border-box;
width: 100%;
height: 24px;
margin: 0;
padding: 0 $sp-2;
border: 1px solid $line;
border-radius: $r-sm;
background: $soft;
color: $subject-ink;
font-family: inherit;
font-size: 11px;
line-height: 1.3;
}
.ir__in:focus-visible { outline: 2px solid $color; outline-offset: -2px; }
.ir__in[aria-invalid="true"] { border-color: $error; background: $error-soft; }
// ── 발판 ─────────────────────────────────────────
.ir__foot {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: $sp-2;
margin-top: $sp-2;
}
.ir__msg {
position: relative;
min-height: 15px;
margin: 0;
font-size: 10px;
}
.ir__msg-a, .ir__msg-b { position: absolute; inset: 0; line-height: 1.4; }
.ir__msg-a { color: $muted; }
.ir__msg-b { color: $error; opacity: 0; }
.ir.is-bad .ir__msg-a { opacity: 0; }
.ir.is-bad .ir__msg-b { opacity: 1; }
.ir__submit {
display: inline-flex;
align-items: center;
gap: $sp-1;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: $subject-blue-deep;
color: $subject-cream;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
cursor: pointer;
}
.ir__submit:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.ir__send { display: inline-flex; width: 12px; height: 12px; }
// ── 격자 미리보기 전용 2초 루프 ─────────────────────
// 제출 → 빈 칸이 남은 3단계로 translateX 슬라이드, 경고는 steps(1, end) 컷. 88% 안에 복귀.
// 시각을 12fps 눈금(1프레임 = 4.1667%)에 맞춰 놨다 — 슬라이드가 눈금 한 칸(33.4%→37.5%)
// 안에서 끝나야 poster(=가장 크게 변한 프레임)가 어긋난 중간 장면이 아니라 다 도착한 3단계가 된다.
// 경고 컷도 같은 칸 안(34%)에 둬서 그 프레임이 되돌아오는 프레임보다 확실히 크게 변한다
@keyframes ir-slide {
0%, 33.4% { transform: translateX(-300%); }
37.5%, 83.4% { transform: translateX(-200%); }
87.5%, 100% { transform: translateX(-300%); }
}
@keyframes ir-press {
0%, 20% { transform: translateY(0); }
25% { transform: translateY(2px); }
31%, 100% { transform: translateY(0); }
}
// 도착한 다음 프레임부터 흔든다 — poster 가 되는 37.5% 프레임은 제자리여야 한다
@keyframes ir-nudge {
0%, 41% { transform: translateX(0); }
46% { transform: translateX(4px); }
52% { transform: translateX(-2px); }
58%, 100% { transform: translateX(0); }
}
@keyframes ir-bar-3 {
0%, 33.9% { background: $line; }
34%, 87.9% { background: $error; }
88%, 100% { background: $line; }
}
@keyframes ir-bar-4 {
0%, 33.9% { background: $color; }
34%, 87.9% { background: $line; }
88%, 100% { background: $color; }
}
@keyframes ir-cap-3 {
0%, 33.9% { color: $muted; }
34%, 87.9% { color: $error; }
88%, 100% { color: $muted; }
}
@keyframes ir-cap-4 {
0%, 33.9% { color: $subject-ink; }
34%, 87.9% { color: $muted; }
88%, 100% { color: $subject-ink; }
}
@keyframes ir-warn {
0%, 33.9% { opacity: 0; transform: scale(.4); }
34%, 87.9% { opacity: 1; transform: scale(1); }
88%, 100% { opacity: 0; transform: scale(.4); }
}
@keyframes ir-bad-in {
0%, 33.9% { border-color: $line; background: $soft; }
34%, 87.9% { border-color: $error; background: $error-soft; }
88%, 100% { border-color: $line; background: $soft; }
}
@keyframes ir-focus {
0%, 33.9% { outline-color: transparent; }
34%, 87.9% { outline-color: $color; }
88%, 100% { outline-color: transparent; }
}
@keyframes ir-msg-a {
0%, 33.9% { opacity: 1; }
34%, 87.9% { opacity: 0; }
88%, 100% { opacity: 1; }
}
@keyframes ir-msg-b {
0%, 33.9% { opacity: 0; }
34%, 87.9% { opacity: 1; }
88%, 100% { opacity: 0; }
}
.ir.is-demo {
.ir__track {
animation-name: ir-slide;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ir__submit {
animation-name: ir-press;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ir__step:nth-child(3) .ir__bar { animation-name: ir-bar-3; }
.ir__step:nth-child(4) .ir__bar { animation-name: ir-bar-4; }
.ir__step .ir__bar {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ir__step:nth-child(3) .ir__cap { animation-name: ir-cap-3; }
.ir__step:nth-child(4) .ir__cap { animation-name: ir-cap-4; }
.ir__step .ir__cap {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ir__step:nth-child(3) .ir__warn {
animation-name: ir-warn;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ir__panel:nth-child(3) .ir__field {
animation-name: ir-nudge;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ir__panel:nth-child(3) .ir__in {
animation-name: ir-bad-in;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ir__panel:nth-child(3) .ir__title {
animation-name: ir-focus;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ir__msg-a {
animation-name: ir-msg-a;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ir__msg-b {
animation-name: ir-msg-b;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.ir { padding: $sp-1 $sp-2; font-size: 11px; }
.ir__view { height: 40px; margin-top: $sp-1; }
.ir__field { grid-template-columns: 48px 1fr; gap: $sp-1; }
.ir__in { height: 22px; }
.ir__foot { margin-top: $sp-1; }
.ir__submit { padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.ir.is-demo .ir__track,
.ir.is-demo .ir__submit,
.ir.is-demo .ir__bar,
.ir.is-demo .ir__cap,
.ir.is-demo .ir__warn,
.ir.is-demo .ir__field,
.ir.is-demo .ir__in,
.ir.is-demo .ir__title,
.ir.is-demo .ir__msg-a,
.ir.is-demo .ir__msg-b { animation: none !important; }
// 움직임만 끄고 되돌아간 결과(3단계 · 경고)는 남긴다
.ir.is-demo .ir__track { transform: translateX(-200%) !important; }
.ir.is-demo .ir__step:nth-child(3) .ir__bar { background: $error !important; }
.ir.is-demo .ir__step:nth-child(3) .ir__cap { color: $error !important; }
.ir.is-demo .ir__step:nth-child(4) .ir__cap { color: $muted !important; }
.ir.is-demo .ir__step:nth-child(3) .ir__warn { opacity: 1 !important; transform: none !important; }
.ir.is-demo .ir__panel:nth-child(3) .ir__in { border-color: $error !important; background: $error-soft !important; }
.ir.is-demo .ir__panel:nth-child(3) .ir__title { outline-color: $color !important; }
.ir.is-demo .ir__msg-a { opacity: 0 !important; }
.ir.is-demo .ir__msg-b { opacity: 1 !important; }
}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);
}
@property --i {
syntax: "<number>";
inherits: true;
initial-value: 3;
}
.ir {
--i: 3;
box-sizing: border-box;
width: min(320px, 92vw);
padding: 8px 12px;
border: 1px solid rgb(87.262745098%, 87.0980392157%, 87.4274509804%);
border-radius: 16px;
background: #ffffff;
box-shadow: 0 2px 8px rgba(23, 20, 26, 0.07);
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.ir__steps {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.ir__step {
display: grid;
gap: 4px;
}
.ir__bar {
height: 4px;
border-radius: 999px;
background: rgb(87.262745098%, 87.0980392157%, 87.4274509804%);
}
.ir__cap {
display: flex;
align-items: center;
gap: 4px;
color: rgba(23, 20, 26, 0.72);
font-size: 10px;
font-weight: 700;
line-height: 1.3;
}
.ir__warn {
display: inline-flex;
width: 11px;
height: 11px;
color: #c2361a;
opacity: 0;
transform: scale(0.4);
}
.ir__step.is-now .ir__bar {
background: #2f6df6;
}
.ir__step.is-now .ir__cap {
color: #17141a;
}
.ir__step.is-bad .ir__bar {
background: #c2361a;
}
.ir__step.is-bad .ir__cap {
color: #c2361a;
}
.ir__step.is-bad .ir__warn {
opacity: 1;
transform: scale(1);
}
.ir__view {
overflow: hidden;
height: 44px;
margin-top: 8px;
}
.ir__track {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 100%;
width: 100%;
transform: translateX(calc(var(--i) * -100%));
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ir__panel {
min-width: 0;
}
.ir__title {
display: inline-block;
margin: 0 0 4px;
padding: 0 4px;
outline: 2px solid transparent;
outline-offset: -2px;
color: rgba(23, 20, 26, 0.72);
font-size: 11px;
font-weight: 700;
line-height: 1.3;
}
.ir__title:focus {
outline-color: #2f6df6;
}
.ir__field {
display: grid;
grid-template-columns: 56px 1fr;
align-items: center;
gap: 8px;
}
.ir__lab {
color: rgba(23, 20, 26, 0.72);
font-size: 11px;
line-height: 1.3;
text-align: right;
}
.ir__in {
box-sizing: border-box;
width: 100%;
height: 24px;
margin: 0;
padding: 0 8px;
border: 1px solid rgb(87.262745098%, 87.0980392157%, 87.4274509804%);
border-radius: 8px;
background: rgb(95.4509803922%, 95.3921568627%, 95.5098039216%);
color: #17141a;
font-family: inherit;
font-size: 11px;
line-height: 1.3;
}
.ir__in:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.ir__in[aria-invalid=true] {
border-color: #c2361a;
background: #ffe0d8;
}
.ir__foot {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.ir__msg {
position: relative;
min-height: 15px;
margin: 0;
font-size: 10px;
}
.ir__msg-a, .ir__msg-b {
position: absolute;
inset: 0;
line-height: 1.4;
}
.ir__msg-a {
color: rgba(23, 20, 26, 0.72);
}
.ir__msg-b {
color: #c2361a;
opacity: 0;
}
.ir.is-bad .ir__msg-a {
opacity: 0;
}
.ir.is-bad .ir__msg-b {
opacity: 1;
}
.ir__submit {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
cursor: pointer;
}
.ir__submit:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.ir__send {
display: inline-flex;
width: 12px;
height: 12px;
}
@keyframes ir-slide {
0%, 33.4% {
transform: translateX(-300%);
}
37.5%, 83.4% {
transform: translateX(-200%);
}
87.5%, 100% {
transform: translateX(-300%);
}
}
@keyframes ir-press {
0%, 20% {
transform: translateY(0);
}
25% {
transform: translateY(2px);
}
31%, 100% {
transform: translateY(0);
}
}
@keyframes ir-nudge {
0%, 41% {
transform: translateX(0);
}
46% {
transform: translateX(4px);
}
52% {
transform: translateX(-2px);
}
58%, 100% {
transform: translateX(0);
}
}
@keyframes ir-bar-3 {
0%, 33.9% {
background: rgb(87.262745098%, 87.0980392157%, 87.4274509804%);
}
34%, 87.9% {
background: #c2361a;
}
88%, 100% {
background: rgb(87.262745098%, 87.0980392157%, 87.4274509804%);
}
}
@keyframes ir-bar-4 {
0%, 33.9% {
background: #2f6df6;
}
34%, 87.9% {
background: rgb(87.262745098%, 87.0980392157%, 87.4274509804%);
}
88%, 100% {
background: #2f6df6;
}
}
@keyframes ir-cap-3 {
0%, 33.9% {
color: rgba(23, 20, 26, 0.72);
}
34%, 87.9% {
color: #c2361a;
}
88%, 100% {
color: rgba(23, 20, 26, 0.72);
}
}
@keyframes ir-cap-4 {
0%, 33.9% {
color: #17141a;
}
34%, 87.9% {
color: rgba(23, 20, 26, 0.72);
}
88%, 100% {
color: #17141a;
}
}
@keyframes ir-warn {
0%, 33.9% {
opacity: 0;
transform: scale(0.4);
}
34%, 87.9% {
opacity: 1;
transform: scale(1);
}
88%, 100% {
opacity: 0;
transform: scale(0.4);
}
}
@keyframes ir-bad-in {
0%, 33.9% {
border-color: rgb(87.262745098%, 87.0980392157%, 87.4274509804%);
background: rgb(95.4509803922%, 95.3921568627%, 95.5098039216%);
}
34%, 87.9% {
border-color: #c2361a;
background: #ffe0d8;
}
88%, 100% {
border-color: rgb(87.262745098%, 87.0980392157%, 87.4274509804%);
background: rgb(95.4509803922%, 95.3921568627%, 95.5098039216%);
}
}
@keyframes ir-focus {
0%, 33.9% {
outline-color: transparent;
}
34%, 87.9% {
outline-color: #2f6df6;
}
88%, 100% {
outline-color: transparent;
}
}
@keyframes ir-msg-a {
0%, 33.9% {
opacity: 1;
}
34%, 87.9% {
opacity: 0;
}
88%, 100% {
opacity: 1;
}
}
@keyframes ir-msg-b {
0%, 33.9% {
opacity: 0;
}
34%, 87.9% {
opacity: 1;
}
88%, 100% {
opacity: 0;
}
}
.ir.is-demo .ir__track {
animation-name: ir-slide;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.ir.is-demo .ir__submit {
animation-name: ir-press;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.ir.is-demo .ir__step:nth-child(3) .ir__bar {
animation-name: ir-bar-3;
}
.ir.is-demo .ir__step:nth-child(4) .ir__bar {
animation-name: ir-bar-4;
}
.ir.is-demo .ir__step .ir__bar {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ir.is-demo .ir__step:nth-child(3) .ir__cap {
animation-name: ir-cap-3;
}
.ir.is-demo .ir__step:nth-child(4) .ir__cap {
animation-name: ir-cap-4;
}
.ir.is-demo .ir__step .ir__cap {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ir.is-demo .ir__step:nth-child(3) .ir__warn {
animation-name: ir-warn;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ir.is-demo .ir__panel:nth-child(3) .ir__field {
animation-name: ir-nudge;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.ir.is-demo .ir__panel:nth-child(3) .ir__in {
animation-name: ir-bad-in;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ir.is-demo .ir__panel:nth-child(3) .ir__title {
animation-name: ir-focus;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ir.is-demo .ir__msg-a {
animation-name: ir-msg-a;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ir.is-demo .ir__msg-b {
animation-name: ir-msg-b;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.ir {
padding: 4px 8px;
font-size: 11px;
}
.ir__view {
height: 40px;
margin-top: 4px;
}
.ir__field {
grid-template-columns: 48px 1fr;
gap: 4px;
}
.ir__in {
height: 22px;
}
.ir__foot {
margin-top: 4px;
}
.ir__submit {
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.ir.is-demo .ir__track,
.ir.is-demo .ir__submit,
.ir.is-demo .ir__bar,
.ir.is-demo .ir__cap,
.ir.is-demo .ir__warn,
.ir.is-demo .ir__field,
.ir.is-demo .ir__in,
.ir.is-demo .ir__title,
.ir.is-demo .ir__msg-a,
.ir.is-demo .ir__msg-b {
animation: none !important;
}
.ir.is-demo .ir__track {
transform: translateX(-200%) !important;
}
.ir.is-demo .ir__step:nth-child(3) .ir__bar {
background: #c2361a !important;
}
.ir.is-demo .ir__step:nth-child(3) .ir__cap {
color: #c2361a !important;
}
.ir.is-demo .ir__step:nth-child(4) .ir__cap {
color: rgba(23, 20, 26, 0.72) !important;
}
.ir.is-demo .ir__step:nth-child(3) .ir__warn {
opacity: 1 !important;
transform: none !important;
}
.ir.is-demo .ir__panel:nth-child(3) .ir__in {
border-color: #c2361a !important;
background: #ffe0d8 !important;
}
.ir.is-demo .ir__panel:nth-child(3) .ir__title {
outline-color: #2f6df6 !important;
}
.ir.is-demo .ir__msg-a {
opacity: 0 !important;
}
.ir.is-demo .ir__msg-b {
opacity: 1 !important;
}
}// 실제 동작: 제출을 누르면 단계를 앞에서부터 훑어 input.value 가 빈 첫 단계로 되돌아간다.
// 화면만 바꾸는 흉내가 아니라 값을 읽고 비교한 결과가 --i 와 aria-invalid 로 나간다.
var ir = document.getElementById('ir');
var irPanels = [].slice.call(ir.querySelectorAll('.ir__panel'));
var irSteps = [].slice.call(ir.querySelectorAll('.ir__step'));
function irGo(n, bad) {
ir.style.setProperty('--i', n);
irSteps.forEach(function (s, i) {
s.classList.toggle('is-now', i === n);
s.classList.toggle('is-bad', bad && i === n);
});
irPanels.forEach(function (p, i) { p.setAttribute('aria-hidden', i === n ? 'false' : 'true'); });
}
function irSubmit() {
ir.classList.remove('is-demo');
var first = -1;
for (var i = 0; i < irPanels.length; i++) { // 앞에서부터 훑는다
var f = irPanels[i].querySelector('.ir__in');
var blank = f.value.trim() === '';
f.setAttribute('aria-invalid', blank ? 'true' : 'false');
if (blank && first < 0) first = i;
}
ir.classList.toggle('is-bad', first >= 0);
if (first < 0) { irGo(irPanels.length - 1, false); return; }
irGo(first, true);
// 되돌아간 단계의 제목이 초점을 받는다. iframe 안이라 부모를 끌지 않게 preventScroll
irPanels[first].querySelector('.ir__title').focus({ preventScroll: true });
}
document.getElementById('ir-submit').addEventListener('click', irSubmit);
ir.addEventListener('input', function (e) {
ir.classList.remove('is-demo', 'is-bad');
e.target.setAttribute('aria-invalid', 'false');
irSteps.forEach(function (s) { s.classList.remove('is-bad'); });
});
ir.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' || e.target.className.indexOf('ir__in') < 0) return;
e.preventDefault();
irSubmit();
});
irGo(irPanels.length - 1, false);import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./IncompleteStepReturn.module.scss";
export type IncompleteStepReturnProps = {
/** 단계마다 { 제목, 칸 이름, 처음 값 }. 처음 값이 빈 문자열인 단계가 제출 시 되돌아갈 단계다 */
steps?: { title: string; label: string; value: string; placeholder?: string }[];
markerLabel?: string;
panelLabel?: string;
cleanHint?: string;
badHint?: string;
submitLabel?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string; "--step"?: string };
type KeyEvt = { key: string; preventDefault(): void };
type ChEvt = { target: { value: string } };
// ambient 가 얇아 DOM 전역 타입을 못 쓴다 — 초점 받을 요소의 최소 꼴만 로컬로 선언
type Focusable = { focus: (o: { preventScroll: boolean }) => void };
const DEFAULT_STEPS = [
{ title: "Step 1 · Name", label: "Name", value: "Dana Kim", placeholder: "" },
{ title: "Step 2 · Contact", label: "Mobile", value: "+1 555 0134", placeholder: "" },
{ title: "Step 3 · Delivery", label: "Address", value: "", placeholder: "Street address" },
{ title: "Step 4 · Request", label: "Request", value: "Leave with the front desk", placeholder: "" },
];
function WarnDot() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88m-8-80V80a8 8 0 0 1 16 0v56a8 8 0 0 1-16 0m20 36a12 12 0 1 1-12-12a12 12 0 0 1 12 12" />
</g>
</svg>
);
}
function SendIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="m223.69 42.18l-58.22 192a8 8 0 0 1-14.92 1.25L108 148l-87.42-42.55a8 8 0 0 1 1.25-14.92l192-58.22a8 8 0 0 1 9.86 9.87" opacity=".2" />
<path d="M227.32 28.68a16 16 0 0 0-15.66-4.08h-.15L19.57 82.84a16 16 0 0 0-2.49 29.8L102 154l41.3 84.87a15.86 15.86 0 0 0 14.44 9.13q.69 0 1.38-.06a15.88 15.88 0 0 0 14-11.51l58.2-191.94v-.15a16 16 0 0 0-4-15.66m-69.49 203.17l-.05.14v-.07l-40.06-82.3l48-48a8 8 0 0 0-11.31-11.31l-48 48l-82.33-40.06h-.07h.14L216 40Z" />
</g>
</svg>
);
}
export default function IncompleteStepReturn({
steps = DEFAULT_STEPS,
markerLabel = "Step markers",
panelLabel = "Application steps",
cleanHint = "Submits once no field is blank",
badHint = "Sent back to the step with a blank",
submitLabel = "Submit",
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: IncompleteStepReturnProps) {
const [values, setValues] = useState<string[]>(steps.map((s) => s.value));
const [step, setStep] = useState(steps.length - 1); // 처음엔 마지막 검토 단계에 서 있다
const [bad, setBad] = useState<number | null>(null);
const [live, setLive] = useState(demo);
// useRef<T>(null) 은 T | null 이라 인덱스 접근이 번거롭다 — 초기 더미로 T 를 채운 상자 하나로 둔다
const titles = useState<Focusable[]>(steps.map(() => ({ focus: () => {} })))[0];
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--step": String(step),
};
function submit() {
setLive(false);
// 단계를 앞에서부터 훑어 빈 값이 처음 나온 단계로 되돌아간다
let first = -1;
for (let i = 0; i < steps.length; i++) {
if (values[i].trim() === "" && first < 0) first = i;
}
setBad(first >= 0 ? first : null);
setStep(first >= 0 ? first : steps.length - 1);
if (first >= 0) titles[first].focus({ preventScroll: true }); // iframe 안에서 부모를 끌지 않게
}
function edit(i: number, v: string) {
setLive(false);
setValues((prev) => prev.map((p, n) => (n === i ? v : p)));
if (bad === i && v.trim() !== "") setBad(null);
}
function headingKey(e: KeyEvt, i: number) {
if (e.key !== "Enter") return;
e.preventDefault();
setStep(i);
}
const cls = [styles.ir, live ? styles.isDemo : "", bad !== null ? styles.isBad : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={panelLabel}>
<ol className={styles.steps} aria-label={markerLabel}>
{steps.map((s, i) => (
<li
key={s.title}
className={[styles.step, i === step ? styles.isNow : "", bad === i ? styles.isBad : ""]
.filter(Boolean)
.join(" ")}
>
<span className={styles.bar} aria-hidden="true" />
<span className={styles.cap}>
{i + 1}
<span className={styles.warn} aria-hidden="true"><WarnDot /></span>
</span>
</li>
))}
</ol>
<div className={styles.view}>
<div className={styles.track}>
{steps.map((s, i) => (
<div className={styles.panel} key={s.title} aria-hidden={i === step ? undefined : true}>
<h3
className={styles.title}
tabIndex={-1}
onFocus={() => setLive(false)}
onKeyDown={(e: KeyEvt) => headingKey(e, i)}
ref={(el: Focusable | null) => { if (el) titles[i] = el; }}
>
{s.title}
</h3>
<div className={styles.field}>
<label className={styles.lab} htmlFor={`ir-in-${i}`}>{s.label}</label>
<input
className={[styles.in, bad === i && values[i].trim() === "" ? styles.invalid : ""]
.filter(Boolean)
.join(" ")}
id={`ir-in-${i}`}
type="text"
value={values[i]}
placeholder={s.placeholder || ""}
aria-invalid={bad === i && values[i].trim() === "" ? true : undefined}
autoComplete="off"
onChange={(e: ChEvt) => edit(i, e.target.value)}
onKeyDown={(e: KeyEvt) => {
if (e.key === "Enter") { e.preventDefault(); submit(); }
}}
/>
</div>
</div>
))}
</div>
</div>
<div className={styles.foot}>
<p className={styles.msg}>
<span className={styles.msgA}>{cleanHint}</span>
<span className={styles.msgB}>{badHint}</span>
</p>
<button className={styles.submit} type="button" onClick={submit}>
<span className={styles.send} aria-hidden="true"><SendIcon /></span>
{submitLabel}
</button>
</div>
</section>
);
}08 · 空欄が残ったステップへ送り返す送信 — 計測 累積面積 5.894% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
送信は最後の画面で押すため、空欄が最初の画面に残っているとき戻り道が最も長くなるのが、このパターンの性質です。
function irSubmit() {
var first = -1;
for (var i = 0; i < irPanels.length; i++) {
var blank = irPanels[i].querySelector('.ir__in')
.value.trim() === '';
irPanels[i].querySelector('.ir__in')
.setAttribute('aria-invalid', blank ? 'true' : 'false');
if (blank && first < 0) first = i;
}
irGo(first, true);
irPanels[first].querySelector('.ir__title')
.focus({ preventScroll: true });
}
09入力中の値を守るインラインの離脱確認
どんな動きか
入力途中に戻るを押すと画面を覆うモーダルが出がちですが、それでは今どんな瞬間だったのかが隠れます。この部品は代わりに、ボタンのすぐ上で確認行が開きます。値が空なら何も聞かずにすぐ戻り、値があるときだけ role="alertdialog" で尋ねます。

<main class="stage">
<section class="lgd is-demo" id="lgd" aria-label="お届け情報の入力ステップ">
<p class="lgd__h">
<span class="lgd__h-a">ステップ2 · 配送情報</span>
<span class="lgd__h-b">ステップ1 · 注文確認</span>
</p>
<div class="lgd__field">
<label class="lgd__lab" for="lgd-addr">お届け先</label>
<input class="lgd__in" id="lgd-addr" type="text" value="ソウル麻浦区楊花路12" autocomplete="off">
</div>
<div class="lgd__guard">
<p class="lgd__idle">入力中の値はまだ未保存です</p>
<div class="lgd__sheet" id="lgd-sheet" role="alertdialog" aria-labelledby="lgd-msg" aria-hidden="true">
<span class="lgd__skin" aria-hidden="true"></span>
<p class="lgd__msg" id="lgd-msg">
<span class="lgd__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M215.46 216H40.54c-12.62 0-20.54-13.21-14.41-23.91l87.46-151.87c6.3-11 22.52-11 28.82 0l87.46 151.87c6.13 10.7-1.79 23.91-14.41 23.91" opacity=".2"/><path d="M236.8 188.09L149.35 36.22a24.76 24.76 0 0 0-42.7 0L19.2 188.09a23.51 23.51 0 0 0 0 23.72A24.35 24.35 0 0 0 40.55 224h174.9a24.35 24.35 0 0 0 21.33-12.19a23.51 23.51 0 0 0 .02-23.72m-13.87 15.71a8.5 8.5 0 0 1-7.48 4.2H40.55a8.5 8.5 0 0 1-7.48-4.2a7.59 7.59 0 0 1 0-7.72l87.45-151.87a8.75 8.75 0 0 1 15 0l87.45 151.87a7.59 7.59 0 0 1-.04 7.72M120 144v-40a8 8 0 0 1 16 0v40a8 8 0 0 1-16 0m20 36a12 12 0 1 1-12-12a12 12 0 0 1 12 12"/></g></svg></span>
<span class="lgd__msg-t">戻ると入力中の値が消えます</span>
</p>
<div class="lgd__acts">
<button class="lgd__go" type="button" id="lgd-go" tabindex="-1">そのまま戻る</button>
<button class="lgd__stay" type="button" id="lgd-stay" tabindex="-1">書き続ける</button>
</div>
</div>
</div>
<div class="lgd__foot">
<button class="lgd__back" type="button" id="lgd-back" aria-expanded="false" aria-controls="lgd-sheet">
<span class="lgd__arrow" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M112 56v144l-72-72Z" opacity=".2"/><path d="M216 120h-96V56a8 8 0 0 0-13.66-5.66l-72 72a8 8 0 0 0 0 11.32l72 72A8 8 0 0 0 120 200v-64h96a8 8 0 0 0 0-16m-112 60.69L51.31 128L104 75.31Z"/></g></svg></span>
<span class="lgd__back-t">戻る</span>
</button>
<button class="lgd__next" type="button">次へ</button>
</div>
</section>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
$line: color.mix($subject-cream, $stage-ink, 16%);
$soft: color.mix($subject-cream, $stage-ink, 8%);
$warn-bg: color.mix($error, $stage-ink, 30%);
.lgd {
box-sizing: border-box;
width: min(304px, 92vw);
padding: $sp-2 $sp-3;
border: 1px solid $line;
border-radius: $r-card;
background: $stage-ink;
color: $subject-cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.lgd__h {
position: relative;
min-height: 15px;
margin: 0 0 $sp-1;
font-size: 11px;
font-weight: 700;
letter-spacing: .04em;
}
.lgd__h-a, .lgd__h-b { position: absolute; inset: 0; line-height: 1.4; }
.lgd__h-a { color: rgba(255, 255, 255, .56); }
.lgd__h-b { color: $subject-mint; opacity: 0; }
// 정적(사람이 조작하는) 경로 — .is-demo 를 떼도 결과가 보여야 한다
.lgd.is-gone .lgd__h-a { opacity: 0; }
.lgd.is-gone .lgd__h-b { opacity: 1; }
.lgd__field {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: $sp-2;
}
.lgd__lab {
color: rgba(255, 255, 255, .5);
font-size: 11px;
line-height: 1.3;
}
.lgd__in {
box-sizing: border-box;
width: 100%;
height: 24px;
margin: 0;
padding: 0 $sp-2;
border: 1px solid $line;
border-radius: $r-sm;
background: $soft;
color: $subject-cream;
font-family: inherit;
font-size: 11px;
line-height: 1.3;
}
.lgd__in:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// ── 버튼 바로 위 확인 줄 ───────────────────────────
// 자리는 늘 잡아 둔다. 열려도 닫혀도 상자 높이가 그대로라 버튼이 튀지 않고,
// 닫힌 동안에도 이 칸은 「저장 전」 안내로 차 있다 (빈 화면을 만들지 않는다)
.lgd__guard {
position: relative;
height: 46px;
margin-top: $sp-2;
}
.lgd__idle {
position: absolute;
inset: 0;
display: flex;
align-items: center;
box-sizing: border-box;
margin: 0;
padding: 0 $sp-2;
border: 1px dashed $line;
border-radius: $r-sm;
color: rgba(255, 255, 255, .72);
font-size: 10px;
line-height: 1.4;
}
.lgd__sheet {
position: absolute;
inset: 0;
display: grid;
align-content: center;
gap: $sp-1;
box-sizing: border-box;
padding: 0 $sp-2;
pointer-events: none;
}
.lgd.is-open .lgd__sheet { pointer-events: auto; }
.lgd.is-open .lgd__idle { opacity: 0; }
// 껍데기만 scaleY 로 펼친다 — 글자는 이 안에 없으므로 눌리지 않는다
.lgd__skin {
position: absolute;
inset: 0;
border: 1px solid $error;
border-radius: $r-sm;
background: $warn-bg;
transform: scaleY(0);
transform-origin: bottom; // 규칙8: 경고 껍데기 하단 변 펼침
transition: transform $duration $easing;
}
.lgd.is-open .lgd__skin { transform: scaleY(1); }
// 내용은 껍데기와 따로 opacity + translateY 로 들어온다
.lgd__msg, .lgd__acts {
position: relative;
opacity: 0;
transform: translateY(6px);
transition: opacity $duration $easing, transform $duration $easing;
}
.lgd.is-open .lgd__msg, .lgd.is-open .lgd__acts { opacity: 1; transform: translateY(0); }
.lgd__msg {
display: flex;
align-items: center;
gap: $sp-1;
margin: 0;
color: $error-soft;
font-size: 10px;
line-height: 1.3;
}
.lgd__ico { display: inline-flex; flex: 0 0 auto; width: 12px; height: 12px; }
.lgd__acts { display: flex; gap: $sp-1; }
.lgd__go, .lgd__stay {
margin: 0;
padding: $sp-1 $sp-2;
border: 1px solid $line;
border-radius: $r-control;
background: transparent;
color: $subject-cream;
font-family: inherit;
font-size: 10px;
font-weight: 700;
line-height: 1.2;
cursor: pointer;
}
.lgd__go { border-color: $error; background: $error; }
.lgd__go, .lgd__stay { outline: 2px solid transparent; outline-offset: 2px; }
.lgd__go:focus-visible, .lgd__stay:focus-visible { outline-color: $subject-cream; }
// ── 발판 ─────────────────────────────────────────
.lgd__foot {
display: grid;
grid-template-columns: auto auto;
justify-content: space-between;
gap: $sp-2;
margin-top: $sp-2;
}
.lgd__back, .lgd__next {
display: inline-flex;
align-items: center;
gap: $sp-1;
margin: 0;
padding: $sp-1 $sp-3;
border: 1px solid $line;
border-radius: $r-pill;
background: transparent;
color: $subject-cream;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
cursor: pointer;
}
.lgd__next { border-color: $subject-blue-deep; background: $subject-blue-deep; }
.lgd__back:focus-visible, .lgd__next:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
.lgd__arrow { display: inline-flex; width: 12px; height: 12px; }
// ── 격자 미리보기 전용 2초 루프 ─────────────────────
// 확인 줄이 빠진 「빈 자리」 가 대표 이미지로 뽑히면 안 되므로(브리프 규칙 8) 시각을
// 12fps 눈금(1프레임 = 4.1667%)에 맞춰 놨다. 열림은 눈금 한 칸(16.8%→20.8%) 안에서
// 통째로 끝나 그 프레임이 가장 크게 변하고 — poster 는 다 펼쳐진 확인 줄이 된다.
// 되돌아갈 때는 같은 양을 네 칸(70.9%→87.5%)에 나눠 접어 프레임당 변화를 열림의 1/4 로 줄인다.
// 자리는 늘 잡혀 있어 닫혀도 안내 줄이 그 칸을 메운다 — 어느 프레임도 빈 화면이 아니다
@keyframes lgd-press {
0%, 8% { transform: translateY(0); }
13% { transform: translateY(2px); }
19%, 100% { transform: translateY(0); }
}
@keyframes lgd-skin {
0%, 16.8% { transform: scaleY(0); }
20.8%, 70.9% { transform: scaleY(1); }
87.5%, 100% { transform: scaleY(0); }
}
@keyframes lgd-fade {
0%, 16.9% { opacity: 0; }
17%, 70.9% { opacity: 1; }
71%, 100% { opacity: 0; }
}
@keyframes lgd-rise {
0%, 17% { transform: translateY(6px); }
29.2%, 70.9% { transform: translateY(0); }
71%, 100% { transform: translateY(6px); }
}
@keyframes lgd-idle {
0%, 16.9% { opacity: 1; }
17%, 87.3% { opacity: 0; }
87.4%, 100% { opacity: 1; }
}
@keyframes lgd-ring {
0%, 16.9% { outline-color: transparent; }
17%, 70.9% { outline-color: $subject-cream; }
71%, 100% { outline-color: transparent; }
}
.lgd.is-demo {
.lgd__back {
animation-name: lgd-press;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
// 접히는 양을 프레임마다 고르게 나눠야 한 프레임이 튀지 않는다 — 여기만 linear
.lgd__skin {
animation-name: lgd-skin;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
// 켜짐·꺼짐은 컷(steps), 자리잡기는 transform 으로 나눠 반투명 유령을 안 만든다
.lgd__msg, .lgd__acts {
animation-name: lgd-fade, lgd-rise;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
}
.lgd__idle {
animation-name: lgd-idle;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.lgd__go {
animation-name: lgd-ring;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.lgd { padding: $sp-1 $sp-2; font-size: 11px; }
.lgd__field { gap: $sp-1; }
.lgd__in { height: 22px; }
.lgd__guard { height: 42px; margin-top: $sp-1; }
.lgd__foot { margin-top: $sp-1; }
.lgd__back, .lgd__next { padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.lgd.is-demo .lgd__back,
.lgd.is-demo .lgd__skin,
.lgd.is-demo .lgd__msg,
.lgd.is-demo .lgd__acts,
.lgd.is-demo .lgd__idle,
.lgd.is-demo .lgd__go { animation: none !important; }
// 움직임만 끄고 「열린 확인 줄」 이라는 결과는 남긴다
.lgd.is-demo .lgd__skin { transform: scaleY(1) !important; }
.lgd.is-demo .lgd__msg,
.lgd.is-demo .lgd__acts { opacity: 1 !important; transform: none !important; }
.lgd.is-demo .lgd__idle { opacity: 0 !important; }
.lgd.is-demo .lgd__go { outline-color: $subject-cream !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lgd {
box-sizing: border-box;
width: min(304px, 92vw);
padding: 8px 12px;
border: 1px solid rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
border-radius: 16px;
background: #17141a;
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.lgd__h {
position: relative;
min-height: 15px;
margin: 0 0 4px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
}
.lgd__h-a, .lgd__h-b {
position: absolute;
inset: 0;
line-height: 1.4;
}
.lgd__h-a {
color: rgba(255, 255, 255, 0.56);
}
.lgd__h-b {
color: #4cd4a6;
opacity: 0;
}
.lgd.is-gone .lgd__h-a {
opacity: 0;
}
.lgd.is-gone .lgd__h-b {
opacity: 1;
}
.lgd__field {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 8px;
}
.lgd__lab {
color: rgba(255, 255, 255, 0.5);
font-size: 11px;
line-height: 1.3;
}
.lgd__in {
box-sizing: border-box;
width: 100%;
height: 24px;
margin: 0;
padding: 0 8px;
border: 1px solid rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
border-radius: 8px;
background: rgb(16.2980392157%, 14.9647058824%, 16.5960784314%);
color: #fff7e6;
font-family: inherit;
font-size: 11px;
line-height: 1.3;
}
.lgd__in:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.lgd__guard {
position: relative;
height: 46px;
margin-top: 8px;
}
.lgd__idle {
position: absolute;
inset: 0;
display: flex;
align-items: center;
box-sizing: border-box;
margin: 0;
padding: 0 8px;
border: 1px dashed rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
border-radius: 8px;
color: rgba(255, 255, 255, 0.72);
font-size: 10px;
line-height: 1.4;
}
.lgd__sheet {
position: absolute;
inset: 0;
display: grid;
align-content: center;
gap: 4px;
box-sizing: border-box;
padding: 0 8px;
pointer-events: none;
}
.lgd.is-open .lgd__sheet {
pointer-events: auto;
}
.lgd.is-open .lgd__idle {
opacity: 0;
}
.lgd__skin {
position: absolute;
inset: 0;
border: 1px solid #c2361a;
border-radius: 8px;
background: rgb(29.137254902%, 11.8431372549%, 10.1960784314%);
transform: scaleY(0);
transform-origin: bottom;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.lgd.is-open .lgd__skin {
transform: scaleY(1);
}
.lgd__msg, .lgd__acts {
position: relative;
opacity: 0;
transform: translateY(6px);
transition: opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.lgd.is-open .lgd__msg, .lgd.is-open .lgd__acts {
opacity: 1;
transform: translateY(0);
}
.lgd__msg {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
color: #ffe0d8;
font-size: 10px;
line-height: 1.3;
}
.lgd__ico {
display: inline-flex;
flex: 0 0 auto;
width: 12px;
height: 12px;
}
.lgd__acts {
display: flex;
gap: 4px;
}
.lgd__go, .lgd__stay {
margin: 0;
padding: 4px 8px;
border: 1px solid rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
border-radius: 12px;
background: transparent;
color: #fff7e6;
font-family: inherit;
font-size: 10px;
font-weight: 700;
line-height: 1.2;
cursor: pointer;
}
.lgd__go {
border-color: #c2361a;
background: #c2361a;
}
.lgd__go, .lgd__stay {
outline: 2px solid transparent;
outline-offset: 2px;
}
.lgd__go:focus-visible, .lgd__stay:focus-visible {
outline-color: #fff7e6;
}
.lgd__foot {
display: grid;
grid-template-columns: auto auto;
justify-content: space-between;
gap: 8px;
margin-top: 8px;
}
.lgd__back, .lgd__next {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0;
padding: 4px 12px;
border: 1px solid rgb(23.5764705882%, 22.0862745098%, 22.9960784314%);
border-radius: 999px;
background: transparent;
color: #fff7e6;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
cursor: pointer;
}
.lgd__next {
border-color: #0a4fe8;
background: #0a4fe8;
}
.lgd__back:focus-visible, .lgd__next:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.lgd__arrow {
display: inline-flex;
width: 12px;
height: 12px;
}
@keyframes lgd-press {
0%, 8% {
transform: translateY(0);
}
13% {
transform: translateY(2px);
}
19%, 100% {
transform: translateY(0);
}
}
@keyframes lgd-skin {
0%, 16.8% {
transform: scaleY(0);
}
20.8%, 70.9% {
transform: scaleY(1);
}
87.5%, 100% {
transform: scaleY(0);
}
}
@keyframes lgd-fade {
0%, 16.9% {
opacity: 0;
}
17%, 70.9% {
opacity: 1;
}
71%, 100% {
opacity: 0;
}
}
@keyframes lgd-rise {
0%, 17% {
transform: translateY(6px);
}
29.2%, 70.9% {
transform: translateY(0);
}
71%, 100% {
transform: translateY(6px);
}
}
@keyframes lgd-idle {
0%, 16.9% {
opacity: 1;
}
17%, 87.3% {
opacity: 0;
}
87.4%, 100% {
opacity: 1;
}
}
@keyframes lgd-ring {
0%, 16.9% {
outline-color: transparent;
}
17%, 70.9% {
outline-color: #fff7e6;
}
71%, 100% {
outline-color: transparent;
}
}
.lgd.is-demo .lgd__back {
animation-name: lgd-press;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.lgd.is-demo .lgd__skin {
animation-name: lgd-skin;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.lgd.is-demo .lgd__msg, .lgd.is-demo .lgd__acts {
animation-name: lgd-fade, lgd-rise;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite, infinite;
}
.lgd.is-demo .lgd__idle {
animation-name: lgd-idle;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.lgd.is-demo .lgd__go {
animation-name: lgd-ring;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.lgd {
padding: 4px 8px;
font-size: 11px;
}
.lgd__field {
gap: 4px;
}
.lgd__in {
height: 22px;
}
.lgd__guard {
height: 42px;
margin-top: 4px;
}
.lgd__foot {
margin-top: 4px;
}
.lgd__back, .lgd__next {
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.lgd.is-demo .lgd__back,
.lgd.is-demo .lgd__skin,
.lgd.is-demo .lgd__msg,
.lgd.is-demo .lgd__acts,
.lgd.is-demo .lgd__idle,
.lgd.is-demo .lgd__go {
animation: none !important;
}
.lgd.is-demo .lgd__skin {
transform: scaleY(1) !important;
}
.lgd.is-demo .lgd__msg,
.lgd.is-demo .lgd__acts {
opacity: 1 !important;
transform: none !important;
}
.lgd.is-demo .lgd__idle {
opacity: 0 !important;
}
.lgd.is-demo .lgd__go {
outline-color: #fff7e6 !important;
}
}// 실제 동작: 입력 칸에 값이 남아 있을 때만 확인 줄이 열린다(흉내가 아니라 value 를 읽는다).
// 열려 있는 동안 Tab 은 줄 안 두 버튼 사이만 돈다 — 짧은 포커스 트랩.
var lgd = document.getElementById('lgd');
var lgdIn = document.getElementById('lgd-addr');
var lgdSheet = document.getElementById('lgd-sheet');
var lgdBack = document.getElementById('lgd-back');
var lgdBtns = [document.getElementById('lgd-go'), document.getElementById('lgd-stay')];
function lgdOpen(on) {
lgd.classList.toggle('is-open', on);
lgdSheet.setAttribute('aria-hidden', on ? 'false' : 'true');
lgdBack.setAttribute('aria-expanded', on ? 'true' : 'false');
lgdBtns.forEach(function (b) { b.tabIndex = on ? 0 : -1; });
// iframe 안이라 부모 페이지가 끌려가지 않게 preventScroll
(on ? lgdBtns[0] : lgdBack).focus({ preventScroll: true });
}
lgdBack.addEventListener('click', function () {
lgd.classList.remove('is-demo');
if (lgdIn.value.trim() === '') { lgd.classList.add('is-gone'); return; }
lgdOpen(true);
});
lgdBtns[1].addEventListener('click', function () { lgdOpen(false); });
lgdBtns[0].addEventListener('click', function () {
lgdIn.value = ''; // 나가기를 고르면 쓰던 값이 실제로 사라진다
lgdOpen(false);
lgd.classList.add('is-gone');
});
lgdSheet.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { lgdOpen(false); return; }
if (e.key !== 'Tab') return;
e.preventDefault();
var i = lgdBtns.indexOf(document.activeElement);
lgdBtns[(i + 1) % 2].focus({ preventScroll: true }); // 둘뿐이라 Tab 과 Shift+Tab 이 같은 짝을 가리킨다
});
lgdIn.addEventListener('input', function () { lgd.classList.remove('is-demo', 'is-gone'); });import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LeaveGuardInline.module.scss";
export type LeaveGuardInlineProps = {
groupLabel?: string;
currentHeading?: string;
leftHeading?: string;
fieldLabel?: string;
initialValue?: string;
idleNote?: string;
guardMessage?: string;
leaveLabel?: string;
stayLabel?: string;
backLabel?: string;
nextLabel?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault(): void };
type ChEvt = { target: { value: string } };
// ambient 가 얇아 DOM 전역 타입을 못 쓴다 — 초점 받을 요소의 최소 꼴만 로컬로 선언
type Focusable = { focus: (o: { preventScroll: boolean }) => void };
type Box<T> = { current: T };
function WarnIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M215.46 216H40.54c-12.62 0-20.54-13.21-14.41-23.91l87.46-151.87c6.3-11 22.52-11 28.82 0l87.46 151.87c6.13 10.7-1.79 23.91-14.41 23.91" opacity=".2" />
<path d="M236.8 188.09L149.35 36.22a24.76 24.76 0 0 0-42.7 0L19.2 188.09a23.51 23.51 0 0 0 0 23.72A24.35 24.35 0 0 0 40.55 224h174.9a24.35 24.35 0 0 0 21.33-12.19a23.51 23.51 0 0 0 .02-23.72m-13.87 15.71a8.5 8.5 0 0 1-7.48 4.2H40.55a8.5 8.5 0 0 1-7.48-4.2a7.59 7.59 0 0 1 0-7.72l87.45-151.87a8.75 8.75 0 0 1 15 0l87.45 151.87a7.59 7.59 0 0 1-.04 7.72M120 144v-40a8 8 0 0 1 16 0v40a8 8 0 0 1-16 0m20 36a12 12 0 1 1-12-12a12 12 0 0 1 12 12" />
</g>
</svg>
);
}
function BackIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M112 56v144l-72-72Z" opacity=".2" />
<path d="M216 120h-96V56a8 8 0 0 0-13.66-5.66l-72 72a8 8 0 0 0 0 11.32l72 72A8 8 0 0 0 120 200v-64h96a8 8 0 0 0 0-16m-112 60.69L51.31 128L104 75.31Z" />
</g>
</svg>
);
}
export default function LeaveGuardInline({
groupLabel = "Delivery detail step",
currentHeading = "Step 2 · Delivery",
leftHeading = "Step 1 · Order check",
fieldLabel = "Ship to",
initialValue = "12 Yanghwa-ro, Mapo-gu",
idleNote = "What you typed is not saved yet",
guardMessage = "Leaving drops what you just typed",
leaveLabel = "Leave anyway",
stayLabel = "Keep writing",
backLabel = "Back",
nextLabel = "Next",
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: LeaveGuardInlineProps) {
const [value, setValue] = useState(initialValue);
const [open, setOpen] = useState(false);
const [gone, setGone] = useState(false);
const [live, setLive] = useState(demo);
const goBtn = useState<Box<Focusable | null>>({ current: null })[0];
const stayBtn = useState<Box<Focusable | null>>({ current: null })[0];
const backBtn = useState<Box<Focusable | null>>({ current: null })[0];
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function openGuard(on: boolean) {
setOpen(on);
// iframe 안이라 부모 페이지가 끌려가지 않게 preventScroll
(on ? goBtn.current : backBtn.current)?.focus({ preventScroll: true });
}
function back() {
setLive(false);
if (value.trim() === "") { setGone(true); return; } // 빈 칸이면 물음 없이 그냥 돌아간다
openGuard(true);
}
function leave() {
setValue(""); // 나가기를 고르면 쓰던 값이 실제로 사라진다
openGuard(false);
setGone(true);
}
function sheetKeys(e: KeyEvt) {
if (e.key === "Escape") { openGuard(false); return; }
if (e.key !== "Tab") return;
e.preventDefault(); // 둘뿐이라 Tab 과 Shift+Tab 이 같은 짝을 가리킨다
const atGo = document.activeElement === goBtn.current;
(atGo ? stayBtn.current : goBtn.current)?.focus({ preventScroll: true });
}
const cls = [styles.lgd, live ? styles.isDemo : "", open ? styles.isOpen : "", gone ? styles.isGone : ""]
.filter(Boolean)
.join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<p className={styles.h}>
<span className={styles.hA}>{currentHeading}</span>
<span className={styles.hB}>{leftHeading}</span>
</p>
<div className={styles.field}>
<label className={styles.lab} htmlFor="lgd-addr">{fieldLabel}</label>
<input
className={styles.in}
id="lgd-addr"
type="text"
value={value}
autoComplete="off"
onChange={(e: ChEvt) => { setLive(false); setGone(false); setValue(e.target.value); }}
/>
</div>
<div className={styles.guard}>
<p className={styles.idle}>{idleNote}</p>
<div
className={styles.sheet}
role="alertdialog"
aria-label={guardMessage}
aria-hidden={!open}
onKeyDown={open ? sheetKeys : undefined}
>
<span className={styles.skin} aria-hidden="true" />
<p className={styles.msg}>
<span className={styles.ico} aria-hidden="true"><WarnIcon /></span>
{guardMessage}
</p>
<div className={styles.acts}>
<button
className={styles.go}
type="button"
tabIndex={open ? 0 : -1}
onClick={leave}
ref={(el: Focusable | null) => { goBtn.current = el; }}
>
{leaveLabel}
</button>
<button
className={styles.stay}
type="button"
tabIndex={open ? 0 : -1}
onClick={() => openGuard(false)}
ref={(el: Focusable | null) => { stayBtn.current = el; }}
>
{stayLabel}
</button>
</div>
</div>
</div>
<div className={styles.foot}>
<button
className={styles.back}
type="button"
aria-expanded={open}
aria-controls="lgd-sheet"
onClick={back}
ref={(el: Focusable | null) => { backBtn.current = el; }}
>
<span className={styles.arrow} aria-hidden="true"><BackIcon /></span>
{backLabel}
</button>
<button className={styles.next} type="button" onClick={() => setLive(false)}>
{nextLabel}
</button>
</div>
</section>
);
}09 · 入力中の値を守るインラインの離脱確認 — 計測 累積面積 9.356% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
開いている間、Tab は行の中の2つのボタンの間だけを巡ります。
lgdSheet.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { lgdOpen(false); return; }
if (e.key !== 'Tab') return;
e.preventDefault();
var i = lgdBtns.indexOf(document.activeElement);
lgdBtns[(i + 1) % 2].focus({ preventScroll: true });
});
lgdBack.addEventListener('click', function () {
if (lgdIn.value.trim() === '') {
lgd.classList.add('is-gone');
return;
}
lgdOpen(true);
});
どこで壊れるか — 落とし穴
一番よく壊れるのは、高さを決め打ちで固定値に埋め込む箇所です。02 の初版ではステップの高さを3つ書き込んでいましたが、説明文が1行増えただけで6番目の欄が箱の外へ出ました。今は次のステップの実際の高さをそのつど測って --h へ入れています。さらにこのカスタムプロパティは @property で長さ型として登録が必要です。未登録だと値を長さではなく単なる文字列と見るため、中間値がなく一気に飛びます。
2番目はオンとオフの強さの差です。06 で完成ステップを畳む瞬間、開くときは見出し1行ぶんしか変わらなかった画面が、畳むときは本体全体が消えてはるかに大きく変わりました。24コマで拾うプレビューは、最も大きく変わるコマを代表画像に選ぶため、畳む瞬間が全員の第一印象になってしまいます。ループでは両方のタイミングをずらし、畳む側にカットを使いませんでした。09 も同じ壁に当たりました。確認行が閉じるコマが開くコマより広く変わり、空の画面が代表画像に選ばれました。開く側を1目盛りの升で一気に点け、閉じる側は同じ量を4升に分けて1コマあたりの変化を減らして、ようやく主役のいるコマが残りました。
3番目は条件なしで離脱確認を付けることです。09 の初版は戻るボタンを押すと値と無関係に確認行が開きました。しかし何も入力していない人に「値が消えます」と尋ねるのは無用な邪魔です。確認行は value.trim() !== '' のときだけ開くように改め、空なら何も聞かずに戻ります。08 の空欄検査も同じ筋です。最後のステップだけで検査すると、前のステップの空欄は永遠に見えません。先頭からすべて走査する理由がそこにあります。
最後は狭い画面です。部品は記事の中で480×300の枠に入り、スマホでは320×200へ縮みます。舞台の余白を引くと縦に使えるのは174pxしかなく、320px 幅で9つの部品の高さを測ったところ、確認リストが最も大きく出ました。そこで狭い画面ではグループごとに最初の値だけを見せ、フォントを10px に下げ、縦の間隔は増やさず減らしました。段階の間に時間の区間を選ぶ欄が入るとタイムピッカー uiがその欄を埋めます。9つ分の規則を1つのフォルダーにまとめてあり、その圧縮ファイルのパスワードは gyxeywcf です。中には今見ている素の版と React 版が同じ値で入っています。
アクセシビリティ
動きを減らす設定(prefers-reduced-motion: reduce)がオンの画面では、9つともループが止まり、結果の状態だけが残ります。ロックが外れたボタンはその色のままで、畳まれたステップは畳まれたまま要約の一行だけを見せ、離脱確認は開いたコマで止まります。動きが消えても「何が確定したか」は消えてはならないからです。
ステップフォームのアクセシビリティは2つの役割に集約されます。進み具合は role="progressbar" に aria-valuenow を実際の数値で更新し(02)、今のステップは aria-current="step" で示します(03)。値の異常は aria-invalid(08)、ロックは aria-disabled(01)、畳み込みは aria-expanded(06)で伝えます。フォーカスの扱いも正直です。ステップが変わるときは見出しにフォーカスを渡しますが、preventScroll と併用するのは、この部品たちがホーム一覧の iframe の中に入っていて、ガードなしに呼ぶと親ページごと引きずられるからです。
| 項目 | 見た目 | スクリーンリーダー | 操作 |
|---|---|---|---|
| 01 ステップのロック | ロックされたボタン→本来の色 | aria-disabled の切替 |
クリック |
| 02 高さの伸縮 | 箱が伸びて縮む | progressbar の値が更新 |
次・前・矢印キー |
| 03 分かれる経路 | 3枠↔4枠の再構成 | aria-current="step" |
ラジオ |
| 04 続きから書く帯 | 上から降りてくる帯 | aria-live による再開の案内 |
続ける・最初から |
| 05 横の要約 | 要約行が入力に同期 | 要約パネルの aria-live |
入力・修正・↑↓ |
| 06 縦型ウィザード | 終わったステップが一行に | aria-expanded・要約の読み上げ |
見出しクリック・Enter |
| 07 確認画面 | 浮かんで沈むグループ | 鉛筆ボタンの aria-label |
クリック |
| 08 空欄への送り返し | 警告アイコンと赤い欄 | aria-invalid・見出しフォーカス |
送信 |
| 09 離脱確認 | ボタンの上の確認行 | alertdialog の案内文 |
Escape・Tab |
複数ステップのフォームにどの属性を付けるべきかは、MDN の aria-current 解説にまとまっています。同じ系統の入力部品はフォームのカテゴリに、JavaScript が実際に動く部品はJS のカテゴリに集めてあります。
FAQ
ステップ分割は CSS だけでできますか?
画面を隠すところまではできます。:target やラジオで、押したときに見える枠を切り替えられます。ただし「空欄なら先へ進めない」と「入力値を覚える」は CSS の守備範囲外です。値を読み、比べ、保存する仕事は JavaScript のもので、9つすべてにその数行を同梱してあります。画面だけ入れ替えるフォームは、08 の検査を通れません。入力欄を一度に全部見たいなら会員登録 フォーム デザイン 入力欄が平面版です。
localStorage には何を保存すればよいですか?
ステップ番号と入力値をそのまま入れたオブジェクト1つで十分です。04 は { step: 3, v: '東京都千代田区' } の形で保存しています。ただし同じブラウザを別の人が使うと下書きが混ざるため、フォームごとにキーを変え、iframe 内など拒否される環境に備えてアクセス全体を try/catch で包むのがこの部品の作法です。センシティブな値は保存しない方が安全です。
入力 フォーム ステップ が10を超えても大丈夫ですか?
02 と 06 はステップ数の制約を受けません。高さの伸縮も畳み込みも配列の長さしか見ないので、20ステップでも3ステップと同じ挙動です。ただし 03 のように一覧全体を作り直す方式は、ステップが増えるほど1回の再描画が重くなるため、分岐は前の数ステップに寄せて構成する方が無難です。10を超えるステップ表示を一目で数えられる人もいません。









