04쓰다 만 자리를 기억하는 이어쓰기 띠
칸을 채울 때마다 단계 번호와 값이 localStorage 에 적히고, 다시 들어오면 「3단계부터 이어서」 라고 적힌 띠가 위에서 내려와 그 자리로 데려다 줍니다.
쓰임중간에 끊기는 긴 신청서, 앱을 껐다 켜도 이어 써야 하는 예약 폼
localStoragearia-livetranslateY

<main class="stage">
<section class="rb is-demo" id="rb" aria-label="쓰다 만 자리를 기억하는 신청서">
<div class="rb__bar" id="rb-bar" role="status" aria-live="polite">
<span class="rb__bar-i" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M136 80v43.47l36.12 21.67a8 8 0 0 1-8.24 13.72l-40-24A8 8 0 0 1 120 128V80a8 8 0 0 1 16 0m-8-48a95.44 95.44 0 0 0-67.92 28.15C52.81 67.51 46.35 74.59 40 82V64a8 8 0 0 0-16 0v40a8 8 0 0 0 8 8h40a8 8 0 0 0 0-16H49c7.15-8.42 14.27-16.35 22.39-24.57a80 80 0 1 1 1.66 114.75a8 8 0 1 0-11 11.64A96 96 0 1 0 128 32"/></g></svg></span>
<p class="rb__bar-t">쓰다 만 자리가 있습니다</p>
<span class="rb__bar-a">
<button class="rb__b rb__b--go" type="button" id="rb-resume" data-t="{n}단계부터 이어서">3단계부터 이어서</button>
<button class="rb__b" type="button" id="rb-fresh">새로 쓰기</button>
</span>
</div>
<div class="rb__form">
<div class="rb__head">
<p class="rb__h">
<span class="rb__now"><b class="rb__d" data-d="1">1</b><b class="rb__d" data-d="3">3</b></span> / 4 · 신청서
</p>
<p class="rb__save" id="rb-save">자동 저장됨</p>
</div>
<div class="rb__dots" aria-hidden="true">
<span class="rb__dot" data-dot="1"><span class="rb__dot-f"></span></span>
<span class="rb__dot" data-dot="2"><span class="rb__dot-f"></span></span>
<span class="rb__dot" data-dot="3"><span class="rb__dot-f"></span></span>
<span class="rb__dot" data-dot="4"><span class="rb__dot-f"></span></span>
</div>
<input class="rb__in" id="rb-in" type="text" placeholder="받는 사람 이름" aria-label="받는 사람 이름">
</div> </section>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
$line: color.mix($subject-ink, $stage-paper, 18%);
$soft: color.mix($subject-ink, $stage-paper, 6%);
$mute: rgba($subject-ink, .72);
.rb {
box-sizing: border-box;
width: min(312px, 92vw);
padding: $sp-3;
border: 1px solid $line;
border-radius: $r-card;
background: $stage-paper;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
// ── 위에서 내려오는 이어쓰기 띠 ─────────────────────
// 띠 자체는 루프에서 사라지지 않는다(지운 뒤 빈 화면이 대표 이미지가 된다)
.rb__bar {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2;
border-radius: $r-control;
background: $soft;
}
.rb__bar-i { display: inline-flex; flex: 0 0 auto; width: 18px; height: 18px; color: $color; }
.rb__bar-t {
flex: 1 1 auto;
margin: 0;
color: $subject-ink;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
}
.rb__bar-a {
display: inline-flex;
gap: $sp-1;
}
.rb__b {
margin: 0;
padding: $sp-1 $sp-2;
border: 1px solid $line;
border-radius: $r-pill;
background: transparent;
color: $mute;
font-family: inherit;
font-size: 10px;
font-weight: 700;
line-height: 1.3;
white-space: nowrap;
cursor: pointer;
}
.rb__b--go { border-color: $subject-blue-deep; background: $subject-blue-deep; color: $subject-cream; }
.rb__b:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// ── 아래 폼 ───────────────────────────────────────
.rb__form { padding-top: $sp-3; }
.rb__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: $sp-2;
}.rb__h {
margin: 0;
color: $mute;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
}
.rb__now { position: relative; display: inline-block; width: 12px; }
.rb__d { position: absolute; inset: 0; color: $subject-ink; font-size: 11px; }
.rb__d[data-d="3"] { visibility: hidden; }
// 정적(사람이 조작하는) 경로 — 이어서를 누르면 3단계 표시가 켜진다
.rb.is-at3 .rb__d[data-d="1"] { visibility: hidden; }
.rb.is-at3 .rb__d[data-d="3"] { visibility: visible; }
.rb__save {
margin: 0;
color: $mute;
font-size: 10px;
line-height: 1.3;
opacity: 0;
}
// 칸을 칠 때마다 저장 표시가 떴다 가라앉는다
.rb.is-on .rb__save { opacity: 1; }
.rb.is-fresh .rb__save { opacity: 0; }
.rb__dots {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: $sp-1;
margin-top: $sp-2;
}
.rb__dot {
height: 4px;
border-radius: $r-pill;
background: $soft;
overflow: hidden;
}
.rb__dot-f {
display: block;
height: 100%;
border-radius: inherit;
background: $color;
transform: scaleX(0);
transform-origin: left center; // 규칙8: 단계 점 좌측에서 채움
transition: transform $duration $easing;
}
// 정적 경로 — 이어서를 누른 단계까지의 점이 차 있다
.rb.is-at3 .rb__dot[data-dot="1"] .rb__dot-f,
.rb.is-at3 .rb__dot[data-dot="2"] .rb__dot-f,
.rb.is-at3 .rb__dot[data-dot="3"] .rb__dot-f { transform: scaleX(1); }
.rb__in {
box-sizing: border-box;
width: 100%;
height: 24px;
margin: $sp-2 0 0;
padding: 0 $sp-2;
border: 1px solid $line;
border-radius: $r-sm;
background: $stage-paper;
color: $subject-ink;
font-family: inherit;
font-size: 11px;
line-height: 1.3;
}
.rb__in:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// ── 격자 미리보기 전용 2초 루프 ─────────────────────
// 띠는 내려와 자리 잡고 그대로 남는다(퇴장 재현 금지).
// 띠 아래 폼의 단계 표시가 1 → 3 으로 컷 점프하고 점이 따라 찬다
@keyframes rb-drop {
0% { transform: translateY(-30px); opacity: 0; }
14%, 100% { transform: translateY(0); opacity: 1; }
}
@keyframes rb-ico {
0%, 5% { transform: scale(1); }
11% { transform: scale(1.2); }
18%, 100% { transform: scale(1); }
}
@keyframes rb-d3 {
0%, 44% { visibility: hidden; }
45%, 100% { visibility: visible; }
}
@keyframes rb-d1 {
0%, 44% { visibility: visible; }
45%, 100% { visibility: hidden; }
}
@keyframes rb-dot3 {
0%, 44% { transform: scaleX(0); }
52%, 100% { transform: scaleX(1); }
}
@keyframes rb-in-pop {
0%, 44% { transform: translateY(0); }
50% { transform: translateY(2px); }
58%, 100% { transform: translateY(0); }
}
.rb.is-demo {
.rb__bar {
animation-name: rb-drop;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.rb__bar-i {
animation-name: rb-ico;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.rb__d[data-d="1"] { animation-name: rb-d1; }
.rb__d[data-d="3"] { animation-name: rb-d3; }
.rb__d {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rb__dot[data-dot="3"] .rb__dot-f { animation-name: rb-dot3; }
.rb__dot[data-dot="3"] .rb__dot-f {
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.rb__in {
animation-name: rb-in-pop;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.rb { padding: $sp-2; }
.rb__bar { gap: $sp-1; padding: $sp-1; }
.rb__b { padding: 0 $sp-1; }
.rb__bar-t { font-size: 10px; }
.rb__form { padding-top: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.rb.is-demo .rb__bar,
.rb.is-demo .rb__bar-i,
.rb.is-demo .rb__d,
.rb.is-demo .rb__dot-f,
.rb.is-demo .rb__in { animation: none !important; transform: none !important; }
// 움직임만 끄고 내려온 띠와 3단계 판정은 남긴다
.rb.is-demo .rb__bar { opacity: 1 !important; }
.rb.is-demo .rb__d[data-d="1"] { visibility: hidden !important; }
.rb.is-demo .rb__d[data-d="3"] { visibility: visible !important; }
.rb.is-demo .rb__dot[data-dot="3"] .rb__dot-f { transform: scaleX(1) !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rb {
box-sizing: border-box;
width: min(312px, 92vw);
padding: 12px;
border: 1px solid rgb(83.6235294118%, 83.4117647059%, 83.8352941176%);
border-radius: 16px;
background: #ffffff;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
}
.rb__bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 12px;
background: rgb(94.5411764706%, 94.4705882353%, 94.6117647059%);
}
.rb__bar-i {
display: inline-flex;
flex: 0 0 auto;
width: 18px;
height: 18px;
color: #2f6df6;
}
.rb__bar-t {
flex: 1 1 auto;
margin: 0;
color: #17141a;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
}
.rb__bar-a {
display: inline-flex;
gap: 4px;
}
.rb__b {
margin: 0;
padding: 4px 8px;
border: 1px solid rgb(83.6235294118%, 83.4117647059%, 83.8352941176%);
border-radius: 999px;
background: transparent;
color: rgba(23, 20, 26, 0.72);
font-family: inherit;
font-size: 10px;
font-weight: 700;
line-height: 1.3;
white-space: nowrap;
cursor: pointer;
}
.rb__b--go {
border-color: #0a4fe8;
background: #0a4fe8;
color: #fff7e6;
}
.rb__b:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.rb__form {
padding-top: 12px;
}
.rb__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.rb__h {
margin: 0;
color: rgba(23, 20, 26, 0.72);
font-size: 11px;
font-weight: 700;
line-height: 1.3;
}
.rb__now {
position: relative;
display: inline-block;
width: 12px;
}
.rb__d {
position: absolute;
inset: 0;
color: #17141a;
font-size: 11px;
}
.rb__d[data-d="3"] {
visibility: hidden;
}
.rb.is-at3 .rb__d[data-d="1"] {
visibility: hidden;
}
.rb.is-at3 .rb__d[data-d="3"] {
visibility: visible;
}
.rb__save {
margin: 0;
color: rgba(23, 20, 26, 0.72);
font-size: 10px;
line-height: 1.3;
opacity: 0;
}
.rb.is-on .rb__save {
opacity: 1;
}
.rb.is-fresh .rb__save {
opacity: 0;
}
.rb__dots {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
margin-top: 8px;
}
.rb__dot {
height: 4px;
border-radius: 999px;
background: rgb(94.5411764706%, 94.4705882353%, 94.6117647059%);
overflow: hidden;
}
.rb__dot-f {
display: block;
height: 100%;
border-radius: inherit;
background: #2f6df6;
transform: scaleX(0);
transform-origin: left center;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rb.is-at3 .rb__dot[data-dot="1"] .rb__dot-f,
.rb.is-at3 .rb__dot[data-dot="2"] .rb__dot-f,
.rb.is-at3 .rb__dot[data-dot="3"] .rb__dot-f {
transform: scaleX(1);
}
.rb__in {
box-sizing: border-box;
width: 100%;
height: 24px;
margin: 8px 0 0;
padding: 0 8px;
border: 1px solid rgb(83.6235294118%, 83.4117647059%, 83.8352941176%);
border-radius: 8px;
background: #ffffff;
color: #17141a;
font-family: inherit;
font-size: 11px;
line-height: 1.3;
}
.rb__in:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
@keyframes rb-drop {
0% {
transform: translateY(-30px);
opacity: 0;
}
14%, 100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes rb-ico {
0%, 5% {
transform: scale(1);
}
11% {
transform: scale(1.2);
}
18%, 100% {
transform: scale(1);
}
}
@keyframes rb-d3 {
0%, 44% {
visibility: hidden;
}
45%, 100% {
visibility: visible;
}
}
@keyframes rb-d1 {
0%, 44% {
visibility: visible;
}
45%, 100% {
visibility: hidden;
}
}
@keyframes rb-dot3 {
0%, 44% {
transform: scaleX(0);
}
52%, 100% {
transform: scaleX(1);
}
}
@keyframes rb-in-pop {
0%, 44% {
transform: translateY(0);
}
50% {
transform: translateY(2px);
}
58%, 100% {
transform: translateY(0);
}
}
.rb.is-demo .rb__bar {
animation-name: rb-drop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.rb.is-demo .rb__bar-i {
animation-name: rb-ico;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.rb.is-demo .rb__d[data-d="1"] {
animation-name: rb-d1;
}
.rb.is-demo .rb__d[data-d="3"] {
animation-name: rb-d3;
}
.rb.is-demo .rb__d {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rb.is-demo .rb__dot[data-dot="3"] .rb__dot-f {
animation-name: rb-dot3;
}
.rb.is-demo .rb__dot[data-dot="3"] .rb__dot-f {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.rb.is-demo .rb__in {
animation-name: rb-in-pop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.rb {
padding: 8px;
}
.rb__bar {
gap: 4px;
padding: 4px;
}
.rb__b {
padding: 0 4px;
}
.rb__bar-t {
font-size: 10px;
}
.rb__form {
padding-top: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.rb.is-demo .rb__bar,
.rb.is-demo .rb__bar-i,
.rb.is-demo .rb__d,
.rb.is-demo .rb__dot-f,
.rb.is-demo .rb__in {
animation: none !important;
transform: none !important;
}
.rb.is-demo .rb__bar {
opacity: 1 !important;
}
.rb.is-demo .rb__d[data-d="1"] {
visibility: hidden !important;
}
.rb.is-demo .rb__d[data-d="3"] {
visibility: visible !important;
}
.rb.is-demo .rb__dot[data-dot="3"] .rb__dot-f {
transform: scaleX(1) !important;
}
}// 실제 동작: 칸을 칠 때마다 단계 번호와 값을 localStorage 에 적고,
// 열 때 저장된 게 있으면 그 단계로 데려다 준다. iframe 이 저장소를 막을 수 있어 try/catch.
var rb = document.getElementById('rb');
var rbIn = document.getElementById('rb-in');
var rbGo = document.getElementById('rb-resume');
var rbMark = document.getElementById('rb-save');
var RB_KEY = 'godrich-265-04';
var rbStep = 1;
function rbRead() {
try { return JSON.parse(localStorage.getItem(RB_KEY) || 'null'); } catch (e) { return null; }
}
function rbWrite(o) {
try { localStorage.setItem(RB_KEY, JSON.stringify(o)); } catch (e) {}
}
function rbAt(n) { rbStep = n; rb.classList.toggle('is-at3', n >= 3); }
var rbDraft = rbRead();
if (!rbDraft) {
// 데모라 직전 세션이 없다 — 이어쓸 거리가 있도록 예시 초안을 한 번 적어 둔다
rbDraft = { step: 3, v: '' };
rbWrite(rbDraft);
}
rbGo.textContent = rbGo.getAttribute('data-t').replace('{n}', rbDraft.step);
rbIn.addEventListener('input', function () {
rb.classList.remove('is-demo');
rbWrite({ step: rbStep, v: rbIn.value });
rbMark.classList.add('is-on');
});
rbGo.addEventListener('click', function () {
rb.classList.remove('is-demo');
var d = rbRead();
rbAt(d ? d.step : 1);
rbIn.value = d ? d.v : '';
rbIn.focus({ preventScroll: true });
});
document.getElementById('rb-fresh').addEventListener('click', function () {
rb.classList.remove('is-demo');
rbWrite({ step: 1, v: '' });
rb.classList.add('is-fresh');
rbAt(1);
rbIn.value = '';
rbIn.focus({ preventScroll: true });
});
rbAt(1);import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ResumeDraftBanner.module.scss";
export type ResumeDraftBannerProps = {
bannerText?: string;
resumePrefix?: string;
resumeSuffix?: string;
freshLabel?: string;
headingPrefix?: string;
headingSuffix?: string;
fieldLabel?: string;
fieldPlaceholder?: string;
savedLabel?: string;
groupLabel?: string;
totalSteps?: number;
/** 데모라 직전 세션이 없을 때 이어쓸 초안 단계 */
seedStep?: number;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type KeyEvt = { key: string; preventDefault(): void };
type InputEvt = { target: { value: string } };
type Box<T> = { current: T };
const STORAGE_KEY = "godrich-265-04-react";
type Draft = { step: number; v: string };
function readDraft(seedStep: number): Draft {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const d = JSON.parse(raw) as Draft;
if (typeof d.step === "number") return { step: d.step, v: String(d.v || "") };
}
} catch {
// iframe 이 저장소를 막을 수 있다 — 없을 때와 똑같이 논다
}
return { step: seedStep, v: "" };
}
function writeDraft(d: Draft) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(d));
} catch {
// 저장 실패는 화면을 깨지 않는다
}
}
function ClockBack() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2" />
<path d="M136 80v43.47l36.12 21.67a8 8 0 0 1-8.24 13.72l-40-24A8 8 0 0 1 120 128V80a8 8 0 0 1 16 0m-8-48a95.44 95.44 0 0 0-67.92 28.15C52.81 67.51 46.35 74.59 40 82V64a8 8 0 0 0-16 0v40a8 8 0 0 0 8 8h40a8 8 0 0 0 0-16H49c7.15-8.42 14.27-16.35 22.39-24.57a80 80 0 1 1 1.66 114.75a8 8 0 1 0-11 11.64A96 96 0 1 0 128 32" />
</g>
</svg>
);
}
export default function ResumeDraftBanner({
bannerText = "You have an unfinished draft",
resumePrefix = "Resume from step ",
resumeSuffix = "",
freshLabel = "Start over",
headingPrefix = "/ 4 · Application",
headingSuffix = "",
fieldLabel = "Recipient name",
fieldPlaceholder = "Recipient name",
savedLabel = "Saved automatically",
groupLabel = "Application that remembers where you stopped",
totalSteps = 4,
seedStep = 3,
demo = true,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: ResumeDraftBannerProps) {
const [draft, setDraft] = useState<Draft>({ step: 1, v: "" });
const [step, setStep] = useState(1);
const [live, setLive] = useState(demo);
const [saved, setSaved] = useState(false);
const input = useRef<HTMLInputElement>(null) as Box<HTMLInputElement | null>;
// 열 때 저장된 초안을 읽어 띠 문구를 맞춘다
useEffect(() => {
const d = readDraft(seedStep);
setDraft(d);
if (!localStorage.getItem(STORAGE_KEY)) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(d));
} catch {
/* 저장소가 막혀도 띠는 seedStep 로 보여 준다 */
}
}
}, [seedStep]);
function onInput(e: InputEvt) {
setLive(false);
setSaved(true);
const next = { step, v: e.target.value };
setDraft(next);
writeDraft(next);
}
function resume() {
setLive(false);
const d = readDraft(seedStep);
setStep(d.step);
setDraft(d);
if (input.current) {
input.current.value = d.v;
input.current.focus({ preventScroll: true });
}
}
function fresh() {
setLive(false);
const next = { step: 1, v: "" };
setStep(1);
setDraft(next);
setSaved(false);
writeDraft(next);
if (input.current) {
input.current.value = "";
input.current.focus({ preventScroll: true });
}
}
function onKey(e: KeyEvt) {
if (e.key === "Enter") {
e.preventDefault();
resume();
}
}
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = [styles.rb, live ? styles.isDemo : "", step >= 3 ? styles.isAt3 : "", saved ? styles.isOn : ""]
.filter(Boolean)
.join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<div className={styles.bar} role="status" aria-live="polite">
<span className={styles.barI} aria-hidden="true"><ClockBack /></span>
<p className={styles.barT}>{bannerText}</p>
<span className={styles.barA}>
<button className={[styles.b, styles.bGo].join(" ")} type="button" onClick={resume}>
{resumePrefix + draft.step + resumeSuffix}
</button>
<button className={styles.b} type="button" onClick={fresh}>
{freshLabel}
</button>
</span>
</div>
<div className={styles.form}>
<div className={styles.head}>
<p className={styles.h}>
<span className={styles.now}>
<b className={styles.d} data-d="1">1</b>
<b className={styles.d} data-d="3">3</b>
</span>
{" "}{headingPrefix}{headingSuffix}
</p>
<p className={styles.save}>{savedLabel}</p>
</div>
<div className={styles.dots} aria-hidden="true">
{Array.from({ length: totalSteps }, (_, i) => (
<span key={i} className={styles.dot} data-dot={i + 1}>
<span className={styles.dotF} />
</span>
))}
</div>
<input
ref={input}
className={styles.in}
type="text"
placeholder={fieldPlaceholder}
aria-label={fieldLabel}
onInput={onInput}
onKeyDown={onKey}
/>
</div>
</section>
);
}04 쓰다 만 자리를 기억하는 이어쓰기 띠 — 측정 누적면적 10.174% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 회원가입 단계 ui 9가지 — 갈라지고 이어서 쓰는 폼
이 데모가 속한 글
회원가입 단계 ui 9가지 — 갈라지고 이어서 쓰는 폼회원가입 단계 ui는 한 번에 다 보여 주지 않고 몇 장의 화면으로 쪼개어 순서대로 받아 내는 입력 방식입니다. 아홉 개 전부 값을 실제로 읽고 검사하고 저장하며, 칸을 채우지 않으면 다음 장으로 안 넘어가고 탭을 이 글의 데모 전부 보기


