CSS Input Design: 9 Form Widgets You Can Paste In
CSS input design means the small form fields — phone numbers, card numbers, quantities, tags — that fill checkout and signup forms but need a little JavaScript
Five of the nine (tags, auto-grow, drag-drop, resend timer, inline validation) rarely show up in animation libraries — you mostly meet them on real product screens instead.
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Auto-hyphen phone number
- 02 Card number 4-digit grouping
- 03 Currency input with auto comma
- 04 Quantity +/- stepper
- 05 Tag input
- 06 Auto-growing textarea
- 07 Drag-and-drop file dropzone
- 08 OTP resend timer button
- 09 Inline input validation
The nine are ordered by how a form actually gets filled out, not by popularity: contact and payment details first (01–03), quantity and tags next (04–05), notes and attachments after that (06–07), and identity verification plus final validation last (08–09).
| # | Widget | When you'd use it |
|---|---|---|
| 01 | Auto-hyphen phone number | Signup contact, shipping phone |
| 02 | Card number 4-digit grouping | Checkout card field |
| 03 | Currency input with auto comma | Donation, quote amount |
| 04 | Quantity +/- stepper | Cart quantity, booking guests |
| 05 | Tag input | Keywords, multiple recipients |
| 06 | Auto-growing textarea | Review, support message |
| 07 | Drag-and-drop file dropzone | Profile photo, attachments |
| 08 | OTP resend timer button | Phone or email re-verification |
| 09 | Inline input validation | Email, password validation |
01Auto-hyphen phone number
Type digits only and hyphens land in the right spot automatically, like 010-1234-5678. Pasting a full number reformats it the same way.
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);
});
02Card number 4-digit grouping
A space is inserted every four digits as you type, so the number reads like a real card. The first digit also flags VISA, Mastercard, or 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 "";
}
03Currency input with auto comma
Type digits into a currency-prefixed field and thousands separators appear automatically. Leading zeros are stripped along the way.
function format(raw) {
const d = raw.replace(/\D/g, "").replace(/^0+(?=\d)/, "").slice(0, 12);
return d ? Number(d).toLocaleString("en-US") : "";
}
input.addEventListener("input", () => {
input.value = format(input.value);
});
04Quantity +/- stepper
Each tap changes the number in the middle by one, and the minus button dims and stops responding once the minimum (1) is reached.
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));
05Tag input
Type text and press Enter to turn it into a pill-shaped tag; pressing Backspace on an empty field removes the last tag.
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();
}
});
06Auto-growing textarea
As lines are added, the box itself grows taller instead of scrolling, only switching to a scrollbar past a set max height (132px).
function resize() {
// reset to auto first, or the height never shrinks back down
area.style.height = "auto";
const next = Math.min(area.scrollHeight, 132);
area.style.height = next + "px";
}
area.addEventListener("input", resize);
07Drag-and-drop file dropzone
Dragging a file over the zone thickens its border, and dropping it turns the filename into a chip. Clicking still opens the normal file picker.
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 = "Selected: " + file.name;
});
08OTP resend timer button
Tapping resend disables the button for 60 seconds while the remaining count ticks down on the button label itself, then re-enables at zero.
function cooldown(seconds) {
let left = seconds;
btn.disabled = true;
btn.textContent = left + "s to resend";
const t = setInterval(() => {
left--;
if (left <= 0) {
clearInterval(t);
btn.disabled = false;
btn.textContent = "Resend";
} else {
btn.textContent = left + "s to resend";
}
}, 1000);
}
09Inline input validation
While typing, a green check appears the moment the email format is valid, or a red message if it isn't. An empty field shows nothing at all.
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 = "Check the email format"; }
});
Where this breaks — one real trap
01, 02, and 03 all share the same trap. Whenever you rewrite input.value wholesale, like input.value = format(value), the browser resets the caret to the very end of the string. Click into the middle of an already-formatted number to fix a digit, and the next keystroke sends the caret flying back to the end anyway. For short phone or card numbers this barely matters, but for anything you expect people to edit at length, MDN's HTMLInputElement.setSelectionRange docs show how to save the caret position before reformatting and restore it after. The password for the zip holding all nine files — working vanilla JS and a React version each — is puc8udvv — grab it with the button below.
07's drag-and-drop hides a similar gotcha. Skip calling preventDefault() on the dragover event and the browser falls back to its default behavior (opening the file in a new tab), which means drop never fires at all. The snippet above handles that on purpose.
Accessibility
Every field pairs a label with its input through for, and 04's stepper announces the new count ("Quantity 3") through a visually hidden aria-live region so screen reader users hear the change without seeing the number. 08's resend button reports completion through an aria-live="polite" status line, and 09's validation message uses role="status" so it updates quietly only when the value actually changes. 07's file input stays reachable by Tab and opens the native picker with Enter or Space, so the whole flow works without a mouse. Every color and border transition also honors prefers-reduced-motion, per MDN's docs.
@media (prefers-reduced-motion: reduce) {
.field__input, .drop__zone, .valid__frame { transition: none; }
}
More form-input pieces live in OTP Input UI: 9 States, and the about page explains what this site is for.
FAQ
Can I drop these nine straight into a React project?
Yes. The zip's react/ folder has a .tsx and a .module.scss for every item, ported from the same logic as the vanilla JS in vanilla/. You mostly just pass in a color or label string as props.
Does auto-formatting the card number affect payment security at all?
No. What's shown here only groups digits into a readable 4-digit pattern on screen — actually handling or storing card data is the payment processor's job, and this formatting never sends anything to a server.
Does the numeric keyboard pop up automatically on mobile for the phone and card fields?
Yes, inputmode="numeric" tells most mobile browsers to show the numeric keypad instead of the full keyboard. It's only a hint though, so behavior can vary slightly between browsers.