08Submit that sends you back to the step with a blank left
Submitting scans the steps from the front, returns to the first one holding a blank, pins a warning icon on that step marker, and focuses the step heading with preventScroll.
Best forThe final submit on a long application, checking required fields scattered over several screens
aria-invalidpreventScrolltranslateX

<main class="stage">
<section class="ir is-demo" id="ir" aria-label="Application steps">
<ol class="ir__steps" aria-label="Step markers">
<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">Step 1 · Name</h3>
<div class="ir__field">
<label class="ir__lab" for="ir-f1">Name</label>
<input class="ir__in" id="ir-f1" type="text" value="Dana Kim" aria-invalid="false" autocomplete="off">
</div>
</div>
<div class="ir__panel">
<h3 class="ir__title" tabindex="-1">Step 2 · Contact</h3>
<div class="ir__field">
<label class="ir__lab" for="ir-f2">Mobile</label>
<input class="ir__in" id="ir-f2" type="text" value="+1 555 0134" aria-invalid="false" autocomplete="off">
</div>
</div>
<div class="ir__panel">
<h3 class="ir__title" tabindex="-1">Step 3 · Delivery</h3>
<div class="ir__field">
<label class="ir__lab" for="ir-f3">Address</label>
<input class="ir__in" id="ir-f3" type="text" value="" placeholder="Street address" aria-invalid="false" autocomplete="off">
</div>
</div>
<div class="ir__panel">
<h3 class="ir__title" tabindex="-1">Step 4 · Request</h3>
<div class="ir__field">
<label class="ir__lab" for="ir-f4">Request</label>
<input class="ir__in" id="ir-f4" type="text" value="Leave with the front desk" aria-invalid="false" autocomplete="off">
</div>
</div>
</div>
</div>
<div class="ir__foot">
<p class="ir__msg">
<span class="ir__msg-a">Submits once no field is blank</span>
<span class="ir__msg-b">Sent back to the step with a blank</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">Submit</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 Submit that sends you back to the step with a blank left — Measured area 5.894% · moving frames 11/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
Inputs08 Fields that shake when submit catches them9 Form Validation UI Patterns — As You Type
Inputs04 Quantity that springs back when it passes the stock limitCheckout Page Design — 9 Cart Patterns, No Library
Inputs07 Required-error shake toggle — when a submit attempt hits an empty required box, its horizontal-position value shakes briefly side to sideCSS Custom Checkbox: 9 Interaction Patterns