입력창 디자인 9가지 — 복붙하면 되는 폼 위젯
입력창 디자인은 전화번호, 카드 번호, 수량, 태그처럼 회원가입·결제 폼에서 자주 보이지만 순수 HTML만으로는 제대로 동작하지 않는 입력 위젯을 말합니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 전화번호 자동 하이픈
- 02 카드 번호 4자리 자동 포맷
- 03 금액 콤마 자동 입력
- 04 수량 +/- 스테퍼
- 05 태그 입력창
- 06 자동 늘어나는 textarea
- 07 파일 드래그 앤 드롭 업로드
- 08 인증번호 재전송 타이머 버튼
- 09 실시간 인풋 검증 표시
아홉 개는 인기 순이 아니라 폼을 실제로 채우는 순서로 놓았습니다. 연락처와 결제 정보를 입력하고(01~03), 수량과 관심 태그를 고르고(04~05), 메모를 적고 파일을 첨부하고(06~07), 마지막으로 본인인증과 제출 전 검증을 거치는(08~09) 흐름입니다.
| 번호 | 이름 | 언제 쓰나 |
|---|---|---|
| 01 | 전화번호 자동 하이픈 | 회원가입·배송지 연락처 |
| 02 | 카드 번호 4자리 자동 포맷 | 결제 폼 카드 번호 |
| 03 | 금액 콤마 자동 입력 | 후원·견적 금액 |
| 04 | 수량 +/- 스테퍼 | 장바구니·예약 인원 |
| 05 | 태그 입력창 | 키워드·수신자 다중 등록 |
| 06 | 자동 늘어나는 textarea | 후기·문의 내용 |
| 07 | 파일 드래그 앤 드롭 업로드 | 프로필 사진·첨부파일 |
| 08 | 인증번호 재전송 타이머 버튼 | 휴대폰·이메일 재인증 |
| 09 | 실시간 인풋 검증 표시 | 이메일·비밀번호 검증 |
01전화번호 자동 하이픈
숫자만 입력해도 010-1234-5678처럼 하이픈이 자동으로 들어갑니다. 붙여넣기로 11자리를 한 번에 넣어도 같은 규칙으로 다시 포맷됩니다.
function format(raw) {
const d = raw.replace(/\D/g, "").slice(0, 11);
if (d.length < 4) return d;
if (d.length < 8) return d.slice(0, 3) + "-" + d.slice(3);
return d.slice(0, 3) + "-" + d.slice(3, 7) + "-" + d.slice(7);
}
input.addEventListener("input", () => {
input.value = format(input.value);
});
02카드 번호 4자리 자동 포맷
카드 번호를 입력하면 네 자리마다 공백이 자동으로 들어가 실제 카드처럼 읽힙니다. 앞자리로 VISA·Mastercard·AMEX 여부도 함께 표시합니다.
function format(raw) {
const d = raw.replace(/\D/g, "").slice(0, 16);
const groups = d.match(/.{1,4}/g) || [];
return groups.join(" ");
}
function brandOf(d) {
if (d[0] === "4") return "VISA";
if (/^5[1-5]/.test(d)) return "Mastercard";
return "";
}
03금액 콤마 자동 입력
원화 기호가 앞에 붙은 입력창에 숫자를 넣으면 천 단위마다 콤마가 자동으로 붙습니다. 앞자리의 불필요한 0도 함께 지워집니다.
function format(raw) {
const d = raw.replace(/\D/g, "").replace(/^0+(?=\d)/, "").slice(0, 12);
return d ? Number(d).toLocaleString("ko-KR") : "";
}
input.addEventListener("input", () => {
input.value = format(input.value);
});
04수량 +/- 스테퍼
버튼을 누를 때마다 가운데 숫자가 1씩 바뀌고, 최솟값(1)에서는 − 버튼이 자동으로 흐려지고 눌리지 않습니다.
const MIN = 1, MAX = 99;
function set(v) {
n = Math.min(MAX, Math.max(MIN, v));
value.value = n;
minus.disabled = n <= MIN;
plus.disabled = n >= MAX;
}
minus.addEventListener("click", () => set(n - 1));
plus.addEventListener("click", () => set(n + 1));
05태그 입력창
글자를 쓰고 Enter를 누르면 알약 모양 태그로 바뀌고, 빈 입력창에서 백스페이스를 누르면 마지막 태그가 지워집니다.
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
if (input.value.trim()) tags.push(input.value.trim());
input.value = "";
render();
} else if (e.key === "Backspace" && !input.value && tags.length) {
tags.pop();
render();
}
});
06자동 늘어나는 textarea
줄이 늘어날 때마다 스크롤 대신 입력창 높이 자체가 늘어나고, 정해 둔 최대 높이(132px)를 넘으면 그때부터 스크롤이 생깁니다.
function resize() {
// 먼저 auto로 되돌려야 scrollHeight가 줄어든 만큼도 다시 잰다
area.style.height = "auto";
const next = Math.min(area.scrollHeight, 132);
area.style.height = next + "px";
}
area.addEventListener("input", resize);
07파일 드래그 앤 드롭 업로드
파일을 끌어다 놓으면 영역 테두리가 굵어지고, 놓는 순간 파일 이름이 칩으로 바뀝니다. 클릭하면 일반 파일 선택창도 그대로 열립니다.
zone.addEventListener("dragover", (e) => {
e.preventDefault();
zone.classList.add("is-over");
});
zone.addEventListener("drop", (e) => {
e.preventDefault();
zone.classList.remove("is-over");
const file = e.dataTransfer.files[0];
if (file) filename.textContent = "선택됨: " + file.name;
});
08인증번호 재전송 타이머 버튼
재전송 버튼을 누르면 60초 동안 비활성화되며, 남은 초가 버튼 글자 위에 실시간으로 줄어듭니다. 0초가 되면 다시 눌립니다.
function cooldown(seconds) {
let left = seconds;
btn.disabled = true;
btn.textContent = left + "초 후 재전송";
const t = setInterval(() => {
left--;
if (left <= 0) {
clearInterval(t);
btn.disabled = false;
btn.textContent = "재전송";
} else {
btn.textContent = left + "초 후 재전송";
}
}, 1000);
}
09실시간 인풋 검증 표시
입력하는 동안 이메일 형식이 맞으면 초록 체크가, 틀리면 빨간 문구가 그 자리에서 바로 나타납니다. 값이 비어 있으면 아무 표시도 하지 않습니다.
const RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
input.addEventListener("input", () => {
const v = input.value;
frame.classList.remove("is-ok", "is-bad");
if (!v) { icon.textContent = ""; msg.textContent = ""; return; }
if (RE.test(v)) { frame.classList.add("is-ok"); icon.textContent = "✓"; }
else { frame.classList.add("is-bad"); icon.textContent = "!"; msg.textContent = "이메일 형식을 확인해 주세요"; }
});
어디서 깨지는가 — 함정 하나
01·02·03의 자동 포맷 입력창은 전부 같은 함정을 안고 있습니다. input.value = format(value)처럼 값을 통째로 다시 쓰면, 브라우저는 커서(캐럿) 위치를 항상 문자열 맨 끝으로 되돌립니다. 가운데 자릿수를 고치려고 입력창 중간을 클릭해도, 다음 글자를 치는 순간 커서가 끝으로 튀어버립니다. 짧은 전화번호·카드 번호 입력에서는 크게 거슬리지 않지만, 길게 수정하는 입력창이라면 MDN의 HTMLInputElement.setSelectionRange 문서를 참고해 재포맷 전 커서 위치를 저장했다가 되돌려 주는 처리가 필요합니다. 이 함정을 포함해 아홉 개 파일 전부에 실제로 동작하는 바닐라 JS와 React 버전을 담은 압축 파일의 비밀번호는 puc8udvv이고, 아래 버튼으로 받을 수 있습니다.
07의 드래그 앤 드롭도 비슷하게 놓치기 쉬운 부분이 있습니다. dragover 이벤트에서 preventDefault()를 호출하지 않으면 브라우저가 기본 동작(파일을 새 탭으로 여는 것)을 실행해 버려 drop 이벤트 자체가 발생하지 않습니다. 위 코드 조각에도 이 처리가 들어 있는 이유입니다.
접근성
아홉 개 모두 라벨과 입력창을 label·for로 연결했고, 04의 수량 버튼은 화면에 보이지 않는 aria-live 영역으로 "수량 3개"처럼 변경값을 스크린 리더에 알립니다. 08의 재전송 버튼은 aria-live="polite"인 상태 문구로 재전송 완료를 알리고, 09의 검증 문구는 role="status"로 값이 바뀔 때만 조용히 갱신됩니다. 07의 파일 입력은 화면에서는 숨겨져 있지만 Tab으로 포커스가 가고 Enter·Space로 파일 선택창이 열려, 마우스 없이도 끝까지 쓸 수 있습니다. 색·테두리 전환에는 전부 MDN의 prefers-reduced-motion 문서에 따라 모션을 줄이는 사용자를 위한 처리를 넣었습니다.
@media (prefers-reduced-motion: reduce) {
.field__input, .drop__zone, .valid__frame { transition: none; }
}
다른 폼·인풋 관련 편은 인증번호 입력 UI 9종에서 이어 볼 수 있고, 이 사이트가 무엇을 다루는지는 소개에 정리돼 있습니다.
FAQ
이 아홉 개를 리액트 프로젝트에도 그대로 쓸 수 있습니까?
네. 압축 파일 안 react/ 폴더에 항목마다 .tsx와 .module.scss가 따로 들어 있어, vanilla/의 순수 JS 버전과 같은 동작을 훅으로 옮겨 뒀습니다. props로 색·라벨 문구 정도만 바꿔 쓰면 됩니다.
카드 번호 자동 포맷이 결제 보안에도 영향을 줍니까?
아닙니다. 여기서 다루는 건 화면에 숫자를 보기 좋게 4자리씩 나눠 보여주는 표시 방식일 뿐이고, 카드 정보를 실제로 처리·저장하는 건 결제대행사(PG)의 몫입니다. 자동 포맷 자체는 카드 데이터를 서버로 보내지 않습니다.
전화번호·카드 번호 입력창에서 모바일 숫자 키패드가 자동으로 뜨나요?
네, inputmode="numeric"을 넣어 뒀기 때문에 대부분의 모바일 브라우저가 문자 키패드 대신 숫자 키패드를 먼저 보여줍니다. 다만 이는 힌트일 뿐이라 브라우저마다 미세하게 다르게 동작할 수 있습니다.