9 Form Validation UI Patterns — As You Type
Form validation UI is the layer that checks a typed value against a rule and reports the verdict on screen.
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Floating label with a required mark
- 02 Focus-triggered format example tooltip
- 03 Inline hint that flips while you type
- 04 Requirements checklist that fills in one by one
- 05 Password strength gauge that scores as you type
- 06 Character counter that turns warning near the limit
- 07 Status icon that waits, then rules
- 08 Fields that shake when submit catches them
- 09 Focus ring that walks you to the first error
The order is not popularity. It follows one person filling out a form and getting stuck. You see which boxes are mandatory before you start (01), you see the shape a value should take (02), and you learn whether it fits while you type (03). The middle three are boxes with several rules at once: a checklist you clear item by item (04), a score that rates strength (05), and a length cap you approach (06). The last three are verdicts that arrive late: one that waits for a server (07), one that checks everything at the press of a button (08), and one that carries you to the first error sitting off-screen (09). Every rule lives in the file as a regular expression, and the color and the motion only paint its result.
01Floating label with a required mark
The label sits inside the box and, once you type, rises 8px and shrinks to 78%; when a required box loses focus while empty, the label, its asterisk, and the border all turn the error color. The verdict looks at one more piece of state besides the value: touched. Paint a box red before anyone has touched it and the form is a wall of red the moment it opens.
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(); });
02Focus-triggered format example tooltip
Focusing the phone box slides in a tooltip that reads 010-1234-5678. The example is cut into three pieces, and each piece locks into the confirmed color the moment your value clears that piece's pattern. The value itself is stripped down to digits and reassembled in groups of three and four, so the hyphens appear on their own.
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));
});
03Inline hint that flips while you type
A 16px line is reserved under the email box before anything is written into it, so nothing below shifts when a message appears. The not-yet-valid and the valid sentence sit stacked in that same spot, and only opacity switches between them. Putting aria-live="polite" on that line makes the sentence people read and the sentence a screen reader announces one and the same.
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);
04Requirements checklist that fills in one by one
Each of the three rows under the password carries its own pattern. When a row passes, only that row crosses into the confirmed color and the check inside its circle pops from scale(0) through 1.3 to 1. Keeping the rules in an array means that adding a requirement or reordering them never touches the markup.
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');
}
05Password strength gauge that scores as you type
Four conditions are measured separately and collapsed into a single score from 0 to 4, and the screen reads that one number to decide how many cells light up, which word appears, and what color both take. Cells grow from the left with scaleX, and the track is split evenly by grid-template-columns: repeat(4, 1fr). The three grade words sit stacked, with only one turned on at a time.
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)));
06Character counter that turns warning near the limit
maxlength stops the overflow in the browser, so the script only counts what is left and watches for a threshold crossing. Twelve characters left flips it to warning, zero flips it to stop, and the digits, the bar, and the border all follow that one level. The bar animates with scaleX rather than width.
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 + ' left';
ccSay.textContent = used + ' of ' + CC_MAX + ' characters used';
}
07Status icon that waits, then rules
A box that has to ask a server, like a username check, must not ask on every keystroke, so a setTimeout fires once 400ms after typing stops and any new keystroke throws the pending call away with clearTimeout. A spinner turns while the wait lasts, and when the verdict lands, a check pops in or an X shakes its way in.
var DM_RULE = /^[a-z][a-z0-9_]{4,}$/;
dmInput.addEventListener('input', function () {
clearTimeout(dmTimer);
if (!dmInput.value) { dmSet('idle', 'Enter a username'); return; }
dmSet('wait', 'Checking');
dmTimer = setTimeout(function () {
var ok = DM_RULE.test(dmInput.value);
dmSet(ok ? 'ok' : 'no', ok ? 'This username is available' : 'This username does not fit the rule');
}, 400);
});
08Fields that shake when submit catches them
This one holds its judgment until the submit button is pressed. Only the empty boxes shake sideways through 6px, 4px, and 2px with translateX, and the label on the same row lights up in the error color. To make the same box shake again on a second press, the class has to come off and a reflow has to be forced before it goes back on.
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; // force a reflow so the same shake runs again
row.classList.add('is-bad');
}
});
09Focus ring that walks you to the first error
On a form too tall for one screen, telling someone what is wrong is not enough. When submission is blocked, the first empty box is found, scrollIntoView carries the view to it, a 3px ring lights up on it, and focus is handed over. Passing preventScroll: true along with that focus call is the whole point of this item.
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 }); // without it the parent page is dragged down
});
Where it breaks — the trap
The part of a validation UI that breaks most often is not the pattern but the place the message will occupy. Insert an error line after the fact and everything below drops by the height of that line, which is how a button runs away from under the finger about to press it. That is why 03, 05, 06, and 07 give the message row a height: 16px first and stack the sentences inside it with position: absolute. Whether a line appears or disappears, the outer height stays 16px.
The second trap shows up when a message changes gradually. Cross-fading an error sentence into a success sentence with opacity leaves frames where both are half-visible and neither can be read, and in the 24-frame preview one of those frames was the one picked as the cover image. Message swaps therefore use animation-timing-function: steps(1, end) and land in a single frame. Borders and gauges are the only things here allowed to change color gradually.
The third is the narrow screen. These demos go into the article as 480×300 iframes and shrink to 320×200 on a phone. Take the stage padding out and 174px of height is all that is left, and measured at 320px, the nine components stood between 68px and 124px tall. The three-row checklist in 04 came to 118px and the scroll box in 09 to 124px, the two tallest. With only 50px of slack, a narrow screen is where gaps go down, not up — which is why the checklist rows in 04 drop from 8px to 4px. The nine rule sets are impossible to keep straight when every screen spells them out differently, so they sit in one folder whose archive password is x97m5gb8, and it holds the same vanilla and React versions you are looking at.
Accessibility (reduced-motion)
All nine turn their autoplay loop off under prefers-reduced-motion: reduce while keeping the verdict. The label stands in its raised position without the rise, the check sits in place without the pop, and the shake never runs while the error color and aria-invalid stay exactly as they were. Losing the motion must not mean losing the fact that this box is wrong.
The rule against signaling a verdict by color alone follows from the same idea. Every one of the nine pairs its color with a sentence or an icon, and the table below shows what a screen reader gets.
| Item | On screen | To a screen reader | Trigger |
|---|---|---|---|
| 01 Required label | Label, asterisk, border color | aria-required and aria-invalid |
Judged when focus leaves |
| 02 Format tooltip | Example pieces locking in | Tooltip tied by aria-describedby |
Opens on focus |
| 03 Inline message | One line swapping sentences | aria-live="polite" |
Every keystroke |
| 04 Requirement list | Row color plus a check icon | List tied by aria-describedby |
Every keystroke |
| 05 Strength gauge | Cell count plus a grade word | Off-screen role="status" sentence |
Every keystroke |
| 06 Character count | Digits plus bar color | Remaining count as a sentence | maxlength blocks it |
| 07 Status icon | Spinner, check, X | A sentence per state via aria-live |
400ms after typing stops |
| 08 Submit shake | Shake plus label color | aria-invalid per box |
Submit button |
| 09 First-error jump | 3px focus ring | The moved field's name as a sentence | Submit button |
The markup that lets a program read the verdict is laid out in the MDN page on aria-invalid. Input parts of the same family are collected in the forms category, and the motion that reports a failure in the error category.
FAQ
Is it better to validate while typing or on submit?
Mixing the two works best. For boxes with a clear rule, like an email or a password, telling people as they type, the way 03 and 04 do, cuts down on the round trip through a failed submit. Throwing a red message before anyone has finished typing is just noise, though, so drawing the error only after focus has left once — the way 01 does — is the safe default. The all-at-once pass belongs on the submit button, as in 08.
If the patterns are in the file, can the server skip validation?
Client-side checks exist only to save people a wasted detour, and code running in a browser can be edited by anyone. All nine patterns here were written on the assumption that a server runs the same rules a second time. The same goes for maxlength: it trims what a person types or pastes, but a devtools edit or a script that sets the value slips past it, so the length cap in 06 gets counted again on the server.
Should an error message sit above or below the box?
Below is the safer default. Put it above and the input gets pushed down, so the finger has to chase it, and with a mobile keyboard open the message can be shoved off-screen entirely. The hint in 02 is the exception, because an example shown before typing is better placed where the keyboard will not cover it. Either way, reserving the space first matters more than the side you pick.