GODRICH

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

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.

translateYaria-requiredsteps(1, end)
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.

translateYRegExparia-describedby
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.

aria-livesteps(1, end)RegExp
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.

scaleopacityRegExp
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.

scaleXsteps(1, end)textContent
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.

maxlengthscaleXsteps(1, end)
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.

rotatescalesetTimeout
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.

translateXaria-invalidcubic-bezier
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.

scrollIntoViewpreventScrolloutline
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.

Enter the archive password

The password is inside this article. You will find it as you read.