08빈 칸이 남은 단계로 되돌려 보내는 제출
제출을 누르면 단계를 앞에서부터 훑어 빈 칸이 처음 나온 단계로 되돌아가고, 그 단계 표시에 경고 아이콘이 붙으며 단계 제목이 preventScroll 로 초점을 받습니다.
쓰임긴 신청서의 마지막 제출 버튼, 여러 화면에 흩어진 필수 항목 확인
aria-invalidpreventScrolltranslateX

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


