회원가입 단계 ui 9가지 — 갈라지고 이어서 쓰는 폼
회원가입 단계 ui는 한 번에 다 보여 주지 않고 몇 장의 화면으로 쪼개어 순서대로 받아 내는 입력 방식입니다. 아홉 개 전부 값을 실제로 읽고 검사하고 저장하며, 칸을 채우지 않으면 다음 장으로 안 넘어가고 탭을 닫았다 열어도 쓰던 자리에서 이어집니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 조건이 차야 풀리는 다음 단계 잠금
- 02 단계마다 키를 맞추는 폼 높이 전환
- 03 고른 답에 따라 갈라지는 단계 경로
- 04 쓰다 만 자리를 기억하는 이어쓰기 띠
- 05 치는 대로 쌓이는 옆칸 요약
- 06 끝난 단계가 한 줄로 접히는 세로 위저드
- 07 보내기 전 전부 펼쳐 보는 검토 화면
- 08 빈 칸이 남은 단계로 되돌려 보내는 제출
- 09 쓰던 값을 지키는 인라인 이탈 확인
순서는 폼을 만드는 사람이 아니라 폼을 채우는 사람이 막히는 순서입니다. 먼저 필수 약관이 다 체크되지 않아 다음 버튼이 잠겨 있고(01), 넘어가니 단계마다 칸 수가 달라 상자가 툭툭 튑니다(02). 개인과 사업자로 갈라지는 갈림길이 나오고(03), 중간에 그만둔 사람을 위해 이어쓰기 띠가 떠 있습니다(04). 여기까지가 길을 안내하는 일입니다. 다음 둘은 치는 즉시 확인시키는 일입니다. 입력한 값이 옆에 그대로 쌓이고(05), 폰에서는 세로로 접혀 한 화면에 들어옵니다(06). 마지막 셋은 폼이 사람에게 도리를 지키는 쪽입니다. 보내기 전에 전부 펼쳐 보여 주고(07), 빈 칸이 있으면 그 단계로 되돌려 보내며(08), 뒤로 가기로 값이 사라지기 직전에 한 번 더 묻습니다(09).
01조건이 차야 풀리는 다음 단계 잠금
「다음」 버튼을 처음부터 눌리게 두면 사람은 반응이 없는 이유를 모릅니다. 이 부품은 필수 조건 세 줄이 전부 켜지기 전까지 버튼을 aria-disabled 로 잠그고, 이유 문구를 버튼 옆에 그대로 둡니다. 마지막 조건이 켜지는 순간 잠금은 한 프레임에 풀리고 이유 문구가 민트색의 「넘어갈 수 있습니다」 로 바뀝니다.
function lgSync() {
var done = lgBoxes.filter(function (b) {
return b.getAttribute('aria-checked') === 'true';
}).length;
var ready = done === lgBoxes.length;
lg.classList.toggle('is-ready', ready);
var next = document.getElementById('lg-next');
next.setAttribute('aria-disabled', ready ? 'false' : 'true');
}
02단계마다 키를 맞추는 폼 높이 전환
1단계는 칸이 두 개, 2단계는 세 개, 3단계는 한 개뿐이라면 상자 높이가 단계마다 툭 튑니다. 이 부품은 다음 단계 패널의 실제 높이를 getBoundingClientRect() 로 재어 길이형 커스텀 속성 --h 하나에 넣고, 상자가 그 값까지 흐릅니다. --h 는 @property 로 등록돼 있어 중간값이 생기는 것이 핵심입니다.
@property --h {
syntax: "<length>";
inherits: true;
initial-value: 63px;
}
.ah__box {
--h: 63px;
display: grid;
align-items: start;
height: var(--h);
overflow: hidden;
transition: --h 300ms cubic-bezier(.2, .8, .2, 1);
}
03고른 답에 따라 갈라지는 단계 경로
개인과 사업자는 필요한 단계 수부터 다릅니다. 라디오를 고르는 순간 단계 배열을 걸러 목록을 통째로 다시 그려, 사업자를 고르면 「사업자 정보」 칸이 끼어들어 세 칸이 네 칸이 되고 균등 눈금도 배열 길이로 다시 적힙니다. 지금 서 있는 칸에만 aria-current="step" 이 붙습니다.
function bpDraw() {
var picked = bp.querySelector('.bp__r:checked');
var biz = !!picked && picked.value === 'business';
var list = bpSrc.filter(function (n) {
return n.getAttribute('data-k') === 'all' || biz;
});
bpSteps.style.gridTemplateColumns =
'repeat(' + list.length + ', 1fr)';
list.forEach(function (n, i) {
if (i + 1 === bpAt) li.setAttribute('aria-current', 'step');
});
}
04쓰다 만 자리를 기억하는 이어쓰기 띠
긴 신청서는 대부분 중간에 끊깁니다. 칸을 칠 때마다 단계 번호와 값을 localStorage 에 적어 두고, 다시 들어온 사람에게 「3단계부터 이어서」 라고 적힌 띠를 위에서 내려 보냅니다. 이어서를 누르면 저장된 단계와 값이 실제로 폼에 되살아나며, 저장소는 iframe 안에서 막힐 수 있으므로 접근 전부를 try/catch 로 감쌌습니다.
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) {}
}
rbGo.textContent = rbGo.getAttribute('data-t')
.replace('{n}', rbDraft.step);
05치는 대로 쌓이는 옆칸 요약
결제 화면에서 가장 불안한 순간은 내가 뭘 골랐는지 안 보일 때입니다. 이 부품은 왼쪽 칸에 글자를 넣는 그 순간 오른쪽 요약 줄이 같이 바뀌고, 요약 옆 고치기 단추를 누르면 그 값을 담은 단계로 곧장 돌아가 해당 칸에 초점을 줍니다. 요약 패널은 aria-live="polite" 로 묶어 값이 바뀌면 낭독기가 다시 읽습니다.
ins.forEach(function (inp, i) {
inp.addEventListener('input', function () {
sp.classList.remove('is-demo');
rows[i].querySelector('.sp__v1').textContent =
inp.value.trim() || '—';
});
});
rows.forEach(function (row, i) {
row.querySelector('.sp__fix').addEventListener('click', function () {
sp.classList.remove('is-demo');
setStep(i);
ins[i].focus({ preventScroll: true });
});
});
06끝난 단계가 한 줄로 접히는 세로 위저드
폰 화면에서는 단계를 가로로 늘어놓을 폭이 없습니다. 이 부품은 단계를 세로로 쌓고, 값이 채워진 단계를 grid-template-rows 가 1fr 에서 0fr 로 접어 머리 한 줄만 남깁니다. 0fr 에서도 세로 패딩은 남으므로 몸통에 min-height: 0 과 overflow: hidden 인 안쪽 래퍼를 두는 것이 이 패턴의 관건입니다.
.vz__body {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 300ms cubic-bezier(.2, .8, .2, 1),
visibility 300ms;
}
.vz__item.is-open .vz__body { grid-template-rows: 1fr; }
.vz__inner {
min-height: 0;
overflow: hidden;
}
07보내기 전 전부 펼쳐 보는 검토 화면
마지막 화면은 앞 단계에서 받은 값을 묶음별로 다시 적어 둡니다. 묶음마다 붙은 연필 단추는 그 단계 번호를 기억하고 있어, 눌린 묶음만 잠깐 떠올랐다 가라앉으며 값이 진짜 입력칸으로 바뀝니다. 다시 누르면 고친 값을 텍스트로 되돌려 적습니다.
g.querySelectorAll('.rv__dd').forEach(function (dd) {
var old = dd.querySelector('input');
if (editing && !old) {
var inp = document.createElement('input');
inp.value = dd.textContent.trim();
dd.textContent = '';
dd.appendChild(inp);
} else if (!editing && old) {
dd.textContent = old.value.trim() || '—';
}
});
08빈 칸이 남은 단계로 되돌려 보내는 제출
중요한 것은 어떤 단계인지가 아니라 빈 칸이 어느 단계에 남았는지이며, 그 자리로 돌아가야 합니다. 이 부품은 제출을 누르면 단계를 앞에서부터 훑어 value 가 빈 첫 단계로 되돌아가고, 그 단계 표시에 느낌표 경고 아이콘이 붙으며 칸에 aria-invalid 가, 단계 제목에 초점이 갑니다. 제출은 마지막 장에서 누르므로 빈 칸이 첫 장에 남아 있을 때 되돌아가는 거리가 가장 멀다는 점이 이 패턴의 성질입니다.
function irSubmit() {
var first = -1;
for (var i = 0; i < irPanels.length; i++) {
var blank = irPanels[i].querySelector('.ir__in')
.value.trim() === '';
irPanels[i].querySelector('.ir__in')
.setAttribute('aria-invalid', blank ? 'true' : 'false');
if (blank && first < 0) first = i;
}
irGo(first, true);
irPanels[first].querySelector('.ir__title')
.focus({ preventScroll: true });
}
09쓰던 값을 지키는 인라인 이탈 확인
입력하다 뒤로 가기를 누르면 화면을 덮는 모달이 뜨는 경우가 많은데, 그러면 방금 무엇을 하려던 순간인지 가려집니다. 이 부품은 대신 버튼 바로 위에서 확인 줄이 펼쳐지게 두고, 값이 있을 때만 role="alertdialog" 로 묻습니다. 값이 비어 있으면 묻지 않고 바로 나가며, 열려 있는 동안 Tab 은 줄 안 두 버튼 사이만 돕습니다.
lgdSheet.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { lgdOpen(false); return; }
if (e.key !== 'Tab') return;
e.preventDefault();
var i = lgdBtns.indexOf(document.activeElement);
lgdBtns[(i + 1) % 2].focus({ preventScroll: true });
});
lgdBack.addEventListener('click', function () {
if (lgdIn.value.trim() === '') {
lgd.classList.add('is-gone');
return;
}
lgdOpen(true);
});
어디서 깨지는가 — 함정
가장 자주 깨지는 곳은 높이를 짐작해 고정값으로 박는 곳입니다. 02 를 처음 만들 때는 단계 높이를 세 개 적어 두었는데, 안내문이 한 줄 늘어나자 여섯 번째 칸이 상자 밖으로 나갔습니다. 그래서 지금은 다음 단계의 실제 높이를 그때그때 재어 --h 에 넣습니다. 덧붙여 이 커스텀 속성은 @property 로 길이형으로 등록해 두어야 합니다. 등록하지 않으면 브라우저가 그 값을 길이가 아니라 그냥 글자로 보아 중간값 없이 한 번에 튑니다.
두 번째는 켜는 것과 끄는 것의 세기 차이입니다. 06 에서 완성 단계를 접는 순간, 펼칠 때는 머리 한 줄만큼만 변하던 화면이 접힐 때는 몸통 전체가 사라지며 훨씬 크게 변했습니다. 24장으로 추리는 미리보기에서는 더 크게 변하는 장이 대표 그림으로 뽑히므로, 접히는 순간이 모두의 첫인상이 됩니다. 그래서 루프에서는 접힘과 펼침의 시각을 떼어 두고 접히는 쪽에 컷을 쓰지 않았습니다. 09 도 같았습니다. 확인 줄이 닫히는 장이 열리는 장보다 넓게 변해 빈 화면이 대표 그림으로 뽑혔습니다. 열림은 한눈금 칸에서 통째로 켜고 닫힘은 같은 양을 네 칸에 나누어 프레임당 변화를 줄인 뒤에야 주인공이 있는 장이 남았습니다.
세 번째는 이탈 확인을 조건 없이 거는 것입니다. 09 를 처음 짤 때는 이전 버튼을 누르면 값과 상관없이 확인 줄이 열렸습니다. 그런데 값을 하나도 쓰지 않은 사람에게 「나가면 값이 사라집니다」 라고 묻는 것은 헛된 방해입니다. 확인 줄은 value.trim() === '' 이 아닐 때만 열게 바꾸었고, 비어 있으면 묻지 않고 바로 나갑니다. 08 의 빈 칸 검사도 같은 맥락입니다. 검사를 마지막 단계에서만 하면 앞 단계의 빈 칸은 영영 안 보입니다. 앞에서부터 전부 훑는 이유가 그것입니다.
마지막은 좁은 화면입니다. 부품은 넓은 화면에서는 폭 480px·높이 300px 칸에 들어가고 폰에서는 320×200 칸으로 줄어듭니다. 무대 여백을 빼면 세로로 쓸 수 있는 건 174px 뿐인데, 320px 폭에서 아홉 부품의 높이를 잰 결과 검토 목록이 가장 컸습니다. 그래서 좁은 화면에서는 묶음마다 첫 값만 보이게 하고 폰트를 10px 로 내렸으며, 세로 간격은 올리지 않고 내렸습니다. 아홉 벌의 규칙을 한 폴더에 모아 두었고 그 압축 파일의 비밀번호는 gyxeywcf 이며 안에는 지금 보고 있는 바닐라 판과 React 판이 같은 값으로 들어 있습니다.
접근성
움직임을 줄여 달라는 설정(prefers-reduced-motion: reduce)이 켜진 화면에서는 아홉 벌 전부에서 반복 루프가 멈추고 결과 상태만 남습니다. 잠금이 풀린 버튼은 풀린 색 그대로 있고, 접힌 단계는 접힌 채 요약 한 줄만 보이며, 이탈 확인 줄은 펼쳐진 장으로 멈춥니다. 움직임이 사라져도 「무엇이 확정됐는가」 는 사라지지 않아야 하기 때문입니다.
단계 폼의 접근성은 두 역할로 정리됩니다. 진행 상태는 role="progressbar" 에 aria-valuenow 를 실제 값으로 갱신하고(02), 지금 단계는 aria-current="step" 으로 표시합니다(03). 값의 이상은 aria-invalid(08), 잠김은 aria-disabled(01), 접힘은 aria-expanded(06) 로 알립니다. 초점도 예외 없이 다룹니다. 단계가 바뀔 때 제목에 초점을 주되 preventScroll 과 함께 쓰는 이유는 이 부품들이 홈 목록의 iframe 안에 들어 있어, 가드 없이 부르면 부모 페이지가 통째로 끌려가기 때문입니다.
| 항목 | 눈으로 | 낭독기로 | 조작 |
|---|---|---|---|
| 01 단계 잠금 | 잠긴 버튼 → 풀린 색 | aria-disabled 토글 |
클릭 |
| 02 높이 전환 | 상자가 늘었다 줄어듦 | progressbar 값 갱신 |
다음·이전·좌우 화살표 |
| 03 갈라지는 경로 | 3칸 ↔ 4칸 재구성 | aria-current="step" |
라디오 |
| 04 이어쓰기 띠 | 위에서 내려오는 띠 | aria-live 로 이어쓰기 안내 |
이어서·새로 쓰기 |
| 05 옆칸 요약 | 요약 줄 실시간 반영 | 요약 패널 aria-live |
입력·고치기·↑↓ |
| 06 세로 위저드 | 완료 단계 한 줄 접힘 | aria-expanded · 요약 낭독 |
머리 클릭 · Enter |
| 07 검토 화면 | 들렀다 가라앉는 묶음 | 연필 단추 aria-label |
클릭 |
| 08 빈 칸 복귀 | 경고 아이콘 + 붉은 칸 | aria-invalid · 제목 초점 |
제출 |
| 09 이탈 확인 | 버튼 위 확인 줄 | alertdialog 안내문 |
Escape · Tab |
여러 단계로 나뉜 폼에 어떤 역할을 붙여야 하는지는 MDN 의 aria-current 문서에 정리되어 있습니다. 같은 계열의 입력 부품은 폼·입력 카테고리에, 자바스크립트가 실제로 도는 부품은 JS 카테고리에 모여 있습니다.
FAQ
단계를 CSS 만으로 나눌 수는 없나요?
화면을 가리는 것까지는 됩니다. 각 단계에 :target 이나 라디오로 규칙을 걸면 눌렀을 때 보이는 칸이 바뀝니다. 다만 「빈 칸이 있으면 못 넘어간다」 와 「쓰던 값을 저장한다」 는 CSS 가 못 합니다. 값을 읽고 비교하고 저장하는 일은 자바스크립트의 몫이라, 이 아홉 개는 전부 그 몇 줄을 함께 줍니다. 화면만 갈아 끼우는 폼은 08 의 검사를 통과할 수 없습니다.
localStorage 에 뭘 저장하면 좋을까요?
단계 번호와 입력값을 그대로 담은 객체 하나면 충분합니다. 04 는 { step: 3, v: '서울시 강남구' } 처럼 단계 번호와 문자열을 함께 저장합니다. 단, 다른 사람이 같은 브라우저를 쓰면 값이 섞이므로 편마다 키를 다르게 잡고, 저장소가 막힌 환경(iframe 안 등)을 대비해 접근 전체를 try/catch 로 감싸는 것이 이 부품의 규칙입니다. 민감한 값은 아예 저장하지 않는 편이 안전합니다.
단계 수가 열 개를 넘으면 어떻게 하나요?
이 가운데 02 와 06 은 단계 수에 제약이 없습니다. 높이 전환과 접힘은 배열 길이만 보므로 열 개도 스무 개도 같습니다. 다만 03 처럼 목록을 통째로 다시 그리는 방식은 단계가 늘수록 한 번의 다시 그림이 무거워지므로, 그때는 갈림을 앞쪽 몇 단계로 몰아두는 구성이 낫습니다. 사람도 열 개 넘는 단계 표시를 한눈에 못 셉니다.