06끝난 단계가 한 줄로 접히는 세로 위저드
폭이 좁은 화면에서는 단계를 가로로 늘어놓을 수 없으므로 세로로 쌓고, 끝난 단계는 grid-template-rows 가 1fr 에서 0fr 로 접히며 입력한 값만 한 줄 요약으로 남습니다.
쓰임폰에서 채우는 배송 정보 단계, 세로로만 자리가 나는 사이드 폼
grid-template-rowsaria-expandedrotate

<main class="stage">
<section class="vz is-demo" id="vz" aria-label="배송 정보 세 단계">
<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">010-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="010-2345-6789" aria-label="연락처" placeholder="010-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 원작 · zip 에 SCSS·React 포함 · 회원가입 단계 ui 9가지 — 갈라지고 이어서 쓰는 폼
이 데모가 속한 글
회원가입 단계 ui 9가지 — 갈라지고 이어서 쓰는 폼회원가입 단계 ui는 한 번에 다 보여 주지 않고 몇 장의 화면으로 쪼개어 순서대로 받아 내는 입력 방식입니다. 아홉 개 전부 값을 실제로 읽고 검사하고 저장하며, 칸을 채우지 않으면 다음 장으로 안 넘어가고 탭을 이 글의 데모 전부 보기

