폼 유효성 검사 UI 9종 — 입력하는 순간 판정하는 오류 안내
폼 유효성 검사는 사람이 친 값이 규칙에 맞는지 그 자리에서 재서 화면으로 알려 주는 장치입니다. 아홉 개 모두 실제 정규식이 돌아가고, 입력하는 순간 판정하는 쪽과 제출을 눌러야 판정하는 쪽이 섞여 있습니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 필수 표시가 붙는 플로트 라벨
- 02 포커스에 뜨는 형식 예시 툴팁
- 03 입력 중 바뀌는 인라인 안내 문구
- 04 하나씩 채워지는 요건 체크리스트
- 05 점수를 재는 비밀번호 강도 게이지
- 06 한도가 가까워지면 경고로 바뀌는 글자 수
- 07 기다렸다가 판정하는 상태 아이콘
- 08 제출을 눌렀을 때 흔들리며 알리는 칸
- 09 첫 오류로 데려가는 포커스 링
순서는 인기순이 아니라 한 사람이 폼을 채우다 막히는 순서입니다. 칸에 들어가기 전에 무엇이 필수인지 보고(01), 어떤 꼴로 써야 하는지 예시를 보고(02), 치는 동안 맞는지 틀리는지 한 줄로 듣습니다(03). 그다음은 규칙이 여러 개인 칸입니다. 요건을 하나씩 지워 나가고(04), 점수로 강도를 재고(05), 길이 한도에 다가갑니다(06). 마지막 셋은 판정이 미뤄지는 경우입니다. 서버 대답을 기다렸다 판정하고(07), 제출을 눌러야 한꺼번에 검사하고(08), 화면 밖에 있는 첫 오류까지 데려갑니다(09). 아홉 개의 규칙은 전부 파일 안에 정규식으로 적혀 있고, 색과 움직임은 그 판정의 결과만 그립니다.
01필수 표시가 붙는 플로트 라벨
라벨이 입력칸 안에 앉아 있다가 글자를 치면 8px 위로 떠오르며 78% 크기로 줄어들고, 필수 칸을 비운 채 포커스가 떠나면 라벨과 별표, 테두리가 함께 오류색으로 바뀝니다. 판정은 값만 보지 않고 touched 라는 상태를 하나 더 봅니다. 한 번도 손대지 않은 칸을 빨갛게 칠하면 폼을 열자마자 온통 빨간 화면이 되기 때문입니다.
function flState() {
var focused = document.activeElement === flInput;
var filled = flInput.value.length > 0;
var bad = flTouched && !filled;
fl.classList.toggle('is-up', focused || filled);
fl.classList.toggle('is-bad', bad);
fl.classList.toggle('is-ok', filled && !bad);
flInput.setAttribute('aria-invalid', bad ? 'true' : 'false');
}
flInput.addEventListener('blur', function () { flTouched = true; flState(); });
02포커스에 뜨는 형식 예시 툴팁
전화번호 칸에 들어가면 010-1234-5678 이 적힌 툴팁이 위에서 내려옵니다. 예시는 세 조각으로 쪼개져 있고, 친 값이 조각의 정규식을 통과할 때마다 그 조각만 확정 색으로 굳습니다. 입력값에서 숫자만 남긴 뒤 세 자리·네 자리로 다시 이어 붙여 하이픈을 자동으로 넣습니다.
var PH_RULES = [/^010/, /^010-?\d{4}/, /^010-?\d{4}-?\d{4}$/];
phInput.addEventListener('input', function () {
var d = phInput.value.replace(/\D/g, '').slice(0, 11);
phInput.value = d.length > 7 ? d.slice(0, 3) + '-' + d.slice(3, 7) + '-' + d.slice(7)
: d.length > 3 ? d.slice(0, 3) + '-' + d.slice(3) : d;
var done = 0;
PH_RULES.forEach(function (re, i) { if (re.test(phInput.value)) done = i + 1; });
ph.setAttribute('data-done', String(done));
});
03입력 중 바뀌는 인라인 안내 문구
이메일 칸 아래에는 문구가 나타날 때 아래 내용이 밀리지 않도록 16px짜리 한 줄을 미리 비워 둡니다. 오류 문구와 완료 문구는 같은 자리에 겹쳐 두고 opacity 하나만 갈아 끼웁니다. 그 줄에 aria-live="polite" 를 걸어 두면 눈으로 읽는 문장과 낭독기가 읽는 문장이 하나로 맞춰집니다.
var IL_MAIL = /^[\w.+-]+@[\w-]+\.[\w.-]{2,}$/;
function ilPaint() {
var ok = IL_MAIL.test(ilInput.value);
il.classList.toggle('is-good', ok);
il.classList.toggle('is-bad', !ok && ilInput.value.length > 0);
ilInput.setAttribute('aria-invalid', ok ? 'false' : 'true');
}
ilInput.addEventListener('input', ilPaint);
04하나씩 채워지는 요건 체크리스트
비밀번호 아래 세 줄이 각자의 정규식을 하나씩 들고 있습니다. 줄 하나가 통과하면 그 줄만 확정 색으로 넘어가고, 동그라미 안의 체크 아이콘이 scale(0) 에서 1.3 을 거쳐 1 로 튀어 들어옵니다. 규칙을 배열로 두면 요건을 늘리거나 순서를 바꿀 때 화면 쪽 코드는 건드리지 않아도 됩니다.
var RQ_RULES = [/^.{9,}$/, /\d/, /[^\w\s]/];
function rqPaint() {
var done = 0;
rqRows.forEach(function (row, i) {
var pass = RQ_RULES[i].test(rqInput.value);
row.classList.toggle('is-pass', pass);
if (pass) done += 1;
});
rqInput.setAttribute('aria-invalid', done === RQ_RULES.length ? 'false' : 'true');
}
05점수를 재는 비밀번호 강도 게이지
네 조건을 각각 재서 0부터 4까지의 점수 하나로 줄이면, 화면은 그 숫자 하나만 보고 칸 수와 낱말, 색을 정합니다. 칸은 scaleX 로 왼쪽에서 자라고 게이지는 grid-template-columns: repeat(4, 1fr) 로 넷을 똑같이 나눠 두었습니다. 등급 낱말은 세 개를 겹쳐 두고 하나만 켭니다.
function pgScore(v) {
var n = 0;
if (/^.{10,}$/.test(v)) n += 1;
if (/[a-z]/.test(v) && /[A-Z]/.test(v)) n += 1;
if (/\d/.test(v)) n += 1;
if (/[^\w\s]/.test(v)) n += 1;
return n;
}
pg.setAttribute('data-score', String(pgScore(pgInput.value)));
06한도가 가까워지면 경고로 바뀌는 글자 수
maxlength 가 한도를 넘는 입력을 브라우저 쪽에서 막고, 자바스크립트는 남은 글자를 세어 경계를 넘었는지만 봅니다. 남은 글자가 12자 이하로 떨어지면 경고, 0이면 정지 단계로 바뀌고 숫자·막대·테두리가 한꺼번에 그 색을 따라갑니다. 진행 막대는 폭이 아니라 scaleX 를 씁니다.
var CC_MAX = 60, CC_NEAR = 12;
function ccPaint() {
var used = ccInput.value.length, left = CC_MAX - used;
cc.setAttribute('data-level', left === 0 ? 'full' : left <= CC_NEAR ? 'near' : 'ok');
ccFill.style.transform = 'scaleX(' + (used / CC_MAX).toFixed(3) + ')';
ccLeft.textContent = left + '자 남음';
ccSay.textContent = CC_MAX + '자 중 ' + used + '자를 썼어요';
}
07기다렸다가 판정하는 상태 아이콘
아이디 중복 확인처럼 서버에 물어야 하는 칸은 한 글자마다 요청을 보내면 안 되므로, 타이핑이 멎고 400ms가 지난 뒤에 한 번만 재도록 setTimeout 을 걸고 새 글자가 들어오면 clearTimeout 으로 앞 예약을 버립니다. 기다리는 동안 회전 표시가 돌다가, 판정이 나면 체크가 튀어나오거나 엑스가 좌우로 흔들리며 들어옵니다.
var DM_RULE = /^[a-z][a-z0-9_]{4,}$/;
dmInput.addEventListener('input', function () {
clearTimeout(dmTimer);
if (!dmInput.value) { dmSet('idle', '아이디를 입력해 주세요'); return; }
dmSet('wait', '확인하는 중이에요');
dmTimer = setTimeout(function () {
var ok = DM_RULE.test(dmInput.value);
dmSet(ok ? 'ok' : 'no', ok ? '쓸 수 있는 아이디예요' : '규칙에 맞지 않는 아이디예요');
}, 400);
});
08제출을 눌렀을 때 흔들리며 알리는 칸
치는 동안 검사하지 않고 제출 버튼을 눌러야 검사하는 쪽입니다. 비어 있는 칸만 translateX 로 좌우 6px·4px·2px씩 줄어들며 흔들리고, 같은 줄의 라벨이 오류색으로 켜집니다. 같은 칸을 다시 눌러도 흔들림이 또 돌게 하려면 클래스를 뗀 뒤 리플로를 한 번 강제해야 합니다.
skRows.forEach(function (row) {
var input = row.querySelector('.sk__input');
var empty = input.value.trim().length === 0;
input.setAttribute('aria-invalid', empty ? 'true' : 'false');
row.classList.toggle('is-ok', !empty);
row.classList.remove('is-bad');
if (empty) {
void row.offsetWidth; // 리플로를 강제해야 같은 흔들림이 다시 돈다
row.classList.add('is-bad');
}
});
09첫 오류로 데려가는 포커스 링
칸이 많아 한 화면에 안 들어가는 폼에서는 "어디가 틀렸는지"를 알려 주는 것만으로 부족합니다. 제출이 막히면 첫 번째 빈 칸을 찾아 scrollIntoView 로 데려가고, 그 칸에 3px 링을 켠 다음 포커스를 넘깁니다. 이때 preventScroll: true 를 함께 넘기는 것이 이 항목의 전부입니다.
fe.addEventListener('submit', function (e) {
e.preventDefault();
var first = null;
feSlots.forEach(function (s) { if (!first && s.getAttribute('data-filled') === '0') first = s; });
if (!first) return;
first.scrollIntoView({ behavior: 'smooth', block: 'center' });
first.classList.add('is-hit');
first.focus({ preventScroll: true }); // 없으면 부모 페이지까지 끌려 내려간다
});
어디서 깨지는가 — 함정
검증 UI에서 가장 자주 깨지는 곳은 정규식이 아니라 문구가 나타날 자리입니다. 오류 한 줄을 나중에 끼워 넣으면 그 줄 높이만큼 아래가 통째로 밀립니다. 버튼을 누르려던 손가락 밑에서 버튼이 도망가는 것이 이 현상입니다. 그래서 03·05·06·07은 문구 줄에 height: 16px 을 먼저 주고 안쪽 문장들을 position: absolute 로 포개 둡니다. 한 줄이 나타나든 사라지든 바깥 높이는 16px 그대로입니다.
두 번째는 문구를 서서히 바꿀 때 생깁니다. 오류 문장과 완료 문장을 opacity 로 교차시키면 그 사이 프레임에 두 문장이 반투명하게 겹쳐 읽을 수 없는 장면이 남습니다. 24장으로 만드는 미리보기에서는 하필 그 장이 대표 그림으로 뽑히기도 했습니다. 그래서 문구 교체에는 animation-timing-function: steps(1, end) 를 써서 한 프레임에 잘라 냅니다. 색이 서서히 변해도 되는 곳은 테두리와 게이지뿐입니다.
세 번째는 좁은 화면입니다. 이 데모들은 글 안에 480×300 iframe 으로 들어가고 폰에서는 320×200 으로 줄어듭니다. 무대 여백을 빼면 세로로 쓸 수 있는 건 174px뿐인데, 320px 폭에서 재어 보니 아홉 부품의 높이는 68px에서 124px 사이였습니다. 요건 세 줄짜리 04는 118px, 스크롤 상자가 들어간 09는 124px로 가장 컸습니다. 여유가 50px밖에 없으니 좁은 화면에서는 간격을 올리지 않고 내립니다. 04의 요건 줄 간격을 8px에서 4px로 내린 것이 그래서입니다. 아홉 벌의 규칙을 화면마다 다르게 적어 두면 관리가 안 되므로 한 폴더에 모아 두었고, 그 압축 파일의 비밀번호는 x97m5gb8 이며 안에는 지금 보고 있는 바닐라 판과 React 판이 같은 값으로 들어 있습니다.
접근성
아홉 개 모두 prefers-reduced-motion: reduce 에서 자동 재생 루프를 끄되 판정 결과는 남깁니다. 라벨은 떠오르는 과정 없이 위에 서 있고, 체크는 튀지 않고 그 자리에 있으며, 흔들림은 아예 돌지 않되 오류색과 aria-invalid 는 그대로 남습니다. 움직임이 사라져도 "이 칸이 틀렸다" 는 사실은 사라지지 않아야 하기 때문입니다.
색만으로 판정을 알리지 않는 것도 같은 이유입니다. 아홉 개 전부 색과 함께 문장이나 아이콘을 하나 더 붙였고, 낭독기에는 아래 표대로 전해집니다.
| 항목 | 눈으로 | 낭독기로 | 조작 |
|---|---|---|---|
| 01 필수 라벨 | 라벨·별표·테두리 색 | aria-required · aria-invalid |
포커스가 떠날 때 판정 |
| 02 형식 툴팁 | 굳는 예시 조각 | aria-describedby 로 툴팁 연결 |
포커스에 열림 |
| 03 인라인 문구 | 한 줄 문장 교체 | aria-live="polite" |
한 글자마다 |
| 04 요건 목록 | 줄 색 + 체크 아이콘 | 목록을 aria-describedby 로 연결 |
한 글자마다 |
| 05 강도 게이지 | 칸 수 + 등급 낱말 | 화면 밖 role="status" 문장 |
한 글자마다 |
| 06 글자 수 | 숫자 + 막대 색 | 남은 수를 문장으로 | maxlength 가 막음 |
| 07 상태 아이콘 | 회전·체크·엑스 | 상태마다 aria-live 문장 |
멎고 400ms 뒤 |
| 08 제출 흔들림 | 흔들림 + 라벨 색 | 칸마다 aria-invalid |
제출 버튼 |
| 09 첫 오류 이동 | 3px 포커스 링 | 옮긴 칸 이름을 문장으로 | 제출 버튼 |
폼 검증에서 무엇을 프로그램이 읽을 수 있게 표시해야 하는지는 MDN 의 aria-invalid 문서에 정리되어 있습니다. 같은 계열의 입력 부품은 폼·입력 카테고리에, 오류를 알리는 움직임은 오류 카테고리에 모여 있습니다.
FAQ
입력하는 동안 검사하는 게 좋나요, 제출할 때 검사하는 게 좋나요?
둘을 섞는 쪽이 낫습니다. 이메일이나 비밀번호처럼 규칙이 분명한 칸은 03·04처럼 치는 동안 알려 주면 제출까지 갔다가 되돌아오는 일이 줄어듭니다. 다만 아직 다 치지도 않았는데 빨간 문구부터 띄우면 방해가 되므로, 01처럼 포커스가 한 번 떠난 뒤부터 오류를 그리는 방식이 무난합니다. 한꺼번에 보는 검사는 08처럼 제출 버튼에 답니다.
정규식을 파일 안에 두면 서버 검증은 안 해도 되나요?
화면 쪽 검사는 사람이 덜 헤매게 하는 장치일 뿐이고, 브라우저에서 돌아가는 코드는 누구나 고칠 수 있습니다. 여기 아홉 개의 정규식은 전부 서버가 같은 규칙을 한 번 더 보는 것을 전제로 만든 것입니다. maxlength 도 같습니다. 사람이 치거나 붙여넣는 값은 잘라 내지만 개발자 도구나 스크립트가 값을 직접 넣으면 그냥 통과하므로, 06의 길이 제한도 서버에서 다시 셉니다.
오류 문구는 칸 위에 두는 게 좋나요, 아래에 두는 게 좋나요?
아래가 무난합니다. 위에 두면 입력칸이 아래로 밀려 손가락이 따라가야 하고, 모바일 키보드가 올라온 상태에서는 문구가 화면 밖으로 밀려나기도 합니다. 다만 02처럼 예시를 미리 보여 주는 힌트는 위가 낫습니다. 값을 치는 동안 키보드가 가리지 않는 자리이기 때문입니다. 어느 쪽이든 자리를 미리 비워 두는 것이 먼저입니다.