06Vertical wizard that folds finished steps into one line
Narrow screens cannot lay steps out sideways, so they stack; a finished step folds as grid-template-rows goes from 1fr to 0fr and leaves only a one-line summary of what you typed.
Best forShipping detail steps filled on a phone, side forms that only have vertical room
grid-template-rowsaria-expandedrotate

<main class="stage">
<section class="vz is-demo" id="vz" aria-label="Delivery details in three steps">
<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">Recipient</span>
<span class="vz__sv">Hong Gildong</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="Hong Gildong" aria-label="Recipient" placeholder="Hong Gildong">
<button class="vz__next" type="button">Next</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">Address</span>
<span class="vz__sv">12-34 Yeoksam-dong, Gangnam-gu, Seoul</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 Yeoksam-dong, Gangnam-gu, Seoul" aria-label="Address" placeholder="12-34 Yeoksam-dong, Gangnam-gu, Seoul">
<button class="vz__next" type="button">Next</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">Phone</span>
<span class="vz__sv">+1 555 0134</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="+1 555 0134" aria-label="Phone" placeholder="+1 555 0134">
<button class="vz__next" type="button">Done</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;
}
}// 실제 동작: 머리 단추는 그 단계를 접고 펼치고, 안의 단추는 입력값을 읽어
// 머리에 한 줄 요약으로 남긴 뒤 Next 단계를 펼쳐 그 칸에 초점을 준다.
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 Vertical wizard that folds finished steps into one line — Measured area 29.447% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · 9 Multi Step Form UI Patterns — Branch and Resume
The post this demo lives in
9 Multi Step Form UI Patterns — Branch and ResumeA multi step form ui splits one long form into screens and collects answers in order. All nine read real values, run rea See all demos in this post
If you like this effect - similar demos
Inputs02 Line item that slides down into the saved-for-later groupCheckout Page Design — 9 Cart Patterns, No Library
Inputs07 Status icon that waits, then rules9 Form Validation UI Patterns — As You Type
Inputs01 Match highlight — locating the typed letters inside each suggestion and bolding that spanAutocomplete Input: 9 Search Dropdowns