SolutionsCase StudiesAI ToolsResourcesAboutContact
Get Your Free AI Automation Audit Book a Free 30-Minute Strategy Call
Playground~/frontend Open editor

~/css/form-validation

7 CSS Form Validation

0 Pure CSS7 with JSPublished 2026-10-05Free · no sign-up

Seven validation patterns that help people fix mistakes: errors that wait until you have typed, messages linked to their fields, an error summary at the top, a live password checklist, a tick for valid input, a rule for checkbox groups and a gentle shake for a wrong code.

01. Errors Only After Interaction

ValidationNo JS

:invalid matches an empty required field the moment the page loads, which paints the form red before anyone types. :user-invalid only matches after the person has changed or left the field, so this form stays calm until there is something to correct.

View code
<form class="fv1" novalidate><label for="fv1e">Work email</label><input id="fv1e" type="email" required placeholder="name@company.com" aria-describedby="fv1m"><p class="e" id="fv1m">Enter an email address like name@company.com.</p><button>Continue</button></form>
.fv1 { display: grid; gap: 6px; width: 280px; max-width: 100%; font-family: Inter, system-ui, sans-serif; color: #101828; }
.fv1 label { font-weight: 700; font-size: 14px; }
.fv1 input { min-width: 0; padding: 11px 12px; border: 1px solid #98a2b3; border-radius: 8px; font: 15px Inter, system-ui, sans-serif; }
.fv1 input:focus { outline: none; border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37,99,235,.2); }
.fv1 .e { display: none; margin: 0; color: #b42318; font-size: 13px; font-weight: 600; }
.fv1 input:user-invalid { border-color: #d92d20; }
.fv1 input:user-invalid + .e { display: block; }
.fv1 button { margin-top: 6px; padding: 11px; border: 0; border-radius: 8px; background: #2563eb; color: #fff; font: 700 14px Inter, system-ui, sans-serif; cursor: pointer; }
.fv1 button:focus-visible { outline: 3px solid #93c5fd; outline-offset: 2px; }
var f = document.querySelector('.fv1');
f.addEventListener('submit', function (e) { e.preventDefault(); var i = f.querySelector('input'); if (!i.checkValidity()) { i.focus(); i.setAttribute('aria-invalid', 'true'); } else { i.removeAttribute('aria-invalid'); } });

02. Inline Messages with aria-describedby

ValidationAccessibility

Each error message has an id and is linked to its field with aria-describedby, and the field gets aria-invalid, so a screen reader reads the label, the message and the value together. Messages appear on blur and update as the person types.

View code
<form class="fv2" novalidate><div class="f"><label for="fv2n">Display name</label><input id="fv2n" required minlength="3" aria-describedby="fv2n-e"><p id="fv2n-e" class="e"></p></div><div class="f"><label for="fv2a">Age</label><input id="fv2a" type="number" min="16" max="120" required aria-describedby="fv2a-e"><p id="fv2a-e" class="e"></p></div></form>
.fv2 { display: grid; gap: 12px; width: 280px; max-width: 100%; font-family: Inter, system-ui, sans-serif; color: #101828; }
.fv2 .f { display: grid; gap: 5px; }
.fv2 label { font-weight: 700; font-size: 14px; }
.fv2 input { min-width: 0; padding: 10px 12px; border: 1px solid #98a2b3; border-radius: 8px; font: 15px Inter, system-ui, sans-serif; }
.fv2 input[aria-invalid="true"] { border-color: #d92d20; background: #fffbfa; }
.fv2 input:focus { outline: 3px solid #93c5fd; outline-offset: 1px; }
.fv2 .e { margin: 0; min-height: 1.2em; color: #b42318; font-size: 13px; }
function msg(i) {
  var v = i.validity;
  if (v.valueMissing) return 'This field is required.';
  if (v.tooShort) return 'Use at least ' + i.minLength + ' characters.';
  if (v.rangeUnderflow) return 'You must be ' + i.min + ' or older.';
  if (v.rangeOverflow || v.badInput) return 'Enter a real age.';
  return '';
}
document.querySelectorAll('.fv2 input').forEach(function (i) {
  var e = document.getElementById(i.getAttribute('aria-describedby'));
  function check() { var m = msg(i); e.textContent = m; i.setAttribute('aria-invalid', m ? 'true' : 'false'); }
  i.addEventListener('blur', check);
  i.addEventListener('input', function () { if (i.getAttribute('aria-invalid') === 'true') check(); });
});

03. Error Summary at the Top

ValidationForms

On submit, a summary box lists every problem as a link to its field and receives focus, the pattern used by many government services. It helps people who cannot see all the inline errors, and each link jumps straight to the field to fix.

View code
<form class="fv3" novalidate><div class="sum" tabindex="-1" hidden><h3>There are 2 problems</h3><ul></ul></div><label for="fv3n">Full name</label><input id="fv3n" required autocomplete="name"><label for="fv3p">Postcode</label><input id="fv3p" required pattern="[A-Za-z0-9 ]{5,8}" autocomplete="postal-code"><button>Submit</button></form>
.fv3 { display: grid; gap: 6px; width: 290px; max-width: 100%; font-family: Inter, system-ui, sans-serif; color: #101828; }
.fv3 .sum { margin-bottom: 8px; padding: 12px 14px; border: 3px solid #b42318; border-radius: 8px; background: #fff; }
.fv3 .sum:focus { outline: 3px solid #fda29b; outline-offset: 2px; }
.fv3 .sum h3 { margin: 0 0 6px; font-size: 15px; }
.fv3 .sum ul { margin: 0; padding-left: 18px; }
.fv3 .sum a { color: #b42318; font-weight: 700; font-size: 14px; }
.fv3 label { margin-top: 6px; font-weight: 700; font-size: 14px; }
.fv3 input { min-width: 0; padding: 10px 12px; border: 1px solid #98a2b3; border-radius: 8px; font: 15px Inter, system-ui, sans-serif; }
.fv3 input[aria-invalid="true"] { border: 2px solid #b42318; }
.fv3 button { margin-top: 10px; padding: 11px; border: 0; border-radius: 8px; background: #101828; color: #fff; font: 700 14px Inter, system-ui, sans-serif; cursor: pointer; }
.fv3 :focus-visible { outline: 3px solid #84adff; outline-offset: 2px; }
var f = document.querySelector('.fv3'), sum = f.querySelector('.sum'), ul = sum.querySelector('ul');
var text = { fv3n: 'Enter your full name', fv3p: 'Enter a postcode, like SW1A 1AA' };
f.addEventListener('submit', function (e) {
  e.preventDefault(); ul.innerHTML = ''; var bad = [];
  f.querySelectorAll('input').forEach(function (i) { var ok = i.checkValidity(); i.setAttribute('aria-invalid', !ok); if (!ok) bad.push(i); });
  if (!bad.length) { sum.hidden = true; return; }
  bad.forEach(function (i) { var li = document.createElement('li'), a = document.createElement('a'); a.href = '#' + i.id; a.textContent = text[i.id]; a.onclick = function (ev) { ev.preventDefault(); i.focus(); }; li.appendChild(a); ul.appendChild(li); });
  sum.querySelector('h3').textContent = bad.length === 1 ? 'There is 1 problem' : 'There are ' + bad.length + ' problems';
  sum.hidden = false; sum.focus();
});

04. Password Rules Checklist

ValidationPassword

The rules are listed under the password field and each one ticks as it is met. Every rule's state is written in text (met or not met) in a visually hidden span, so the checklist is not colour-only. The field also has a show and hide button.

View code
<div class="fv4"><label for="fv4p">New password</label><div class="w"><input id="fv4p" type="password" autocomplete="new-password" aria-describedby="fv4r"><button type="button" aria-pressed="false" aria-label="Show password">Show</button></div><ul id="fv4r"><li data-r="len">At least 10 characters<span class="sr"> not met</span></li><li data-r="num">Contains a number<span class="sr"> not met</span></li><li data-r="case">Upper and lower case letters<span class="sr"> not met</span></li></ul></div>
.fv4 { display: grid; gap: 6px; width: 290px; max-width: 100%; font-family: Inter, system-ui, sans-serif; color: #e2e8f0; }
.fv4 label { font-weight: 700; font-size: 14px; }
.fv4 .w { display: flex; border: 1px solid #475569; border-radius: 8px; background: #111827; }
.fv4 .w:focus-within { border-color: #a78bfa; box-shadow: 0 0 0 3px rgba(167,139,250,.25); }
.fv4 input { flex: 1; min-width: 0; padding: 11px 12px; border: 0; background: transparent; color: #fff; font: 15px Inter, system-ui, sans-serif; }
.fv4 input:focus { outline: none; }
.fv4 .w button { padding: 0 12px; border: 0; background: transparent; color: #c4b5fd; font: 700 13px Inter, system-ui, sans-serif; cursor: pointer; }
.fv4 .w button:focus-visible { outline: 2px solid #c4b5fd; outline-offset: -2px; }
.fv4 ul { margin: 4px 0 0; padding: 0; list-style: none; display: grid; gap: 4px; font-size: 13px; color: #94a3b8; }
.fv4 li::before { content: "○"; display: inline-block; width: 18px; }
.fv4 li.ok { color: #86efac; } .fv4 li.ok::before { content: "✓"; }
.fv4 .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
var i = document.getElementById('fv4p'), lis = document.querySelectorAll('.fv4 li'), b = document.querySelector('.fv4 .w button');
var tests = { len: function (v) { return v.length >= 10; }, num: function (v) { return /\d/.test(v); }, case: function (v) { return /[a-z]/.test(v) && /[A-Z]/.test(v); } };
i.addEventListener('input', function () { lis.forEach(function (li) { var ok = tests[li.dataset.r](i.value); li.classList.toggle('ok', ok); li.querySelector('.sr').textContent = ok ? ' met' : ' not met'; }); });
b.onclick = function () { var show = i.type === 'password'; i.type = show ? 'text' : 'password'; b.textContent = show ? 'Hide' : 'Show'; b.setAttribute('aria-pressed', show); };

05. Valid State with a Tick

ValidationFeedback

Positive feedback for fields where people often doubt themselves, such as a shop address that must follow rules. A tick appears inside the field when it is valid, using :user-valid, and a short text confirmation replaces the hint for screen readers.

View code
<div class="fv5"><label for="fv5u">Choose a shop address</label><div class="w"><span aria-hidden="true">shop.app/</span><input id="fv5u" required pattern="[a-z0-9\-]{3,20}" aria-describedby="fv5m"></div><p id="fv5m">3-20 lowercase letters, numbers or hyphens.</p></div>
.fv5 { display: grid; grid-template-columns: minmax(0, 1fr); gap: 6px; width: 290px; max-width: 100%; font-family: Inter, system-ui, sans-serif; color: #101828; }
.fv5 label { font-weight: 700; font-size: 14px; }
.fv5 .w { position: relative; display: flex; align-items: center; border: 1px solid #98a2b3; border-radius: 8px; background: #fff; }
.fv5 .w span { padding-left: 12px; color: #475467; font-size: 15px; }
.fv5 input { flex: 1; min-width: 0; padding: 11px 34px 11px 2px; border: 0; background: transparent; font: 15px Inter, system-ui, sans-serif; }
.fv5 input:focus { outline: none; }
.fv5 .w:focus-within { border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37,99,235,.2); }
.fv5 .w:has(input:user-valid) { border-color: #079455; }
.fv5 .w:has(input:user-valid)::after { content: "✓"; position: absolute; right: 12px; color: #079455; font-weight: 800; }
.fv5 .w:has(input:user-invalid) { border-color: #d92d20; }
.fv5 p { margin: 0; color: #475467; font-size: 13px; }
.fv5 .w:has(input:user-valid) + p { color: #067647; font-weight: 600; }
var i = document.getElementById('fv5u'), p = document.getElementById('fv5m'), hint = p.textContent;
i.addEventListener('blur', function () { p.textContent = i.checkValidity() ? 'shop.app/' + i.value + ' is a valid address.' : hint; i.setAttribute('aria-invalid', !i.checkValidity()); });

06. Checkbox Group Validation

ValidationCheckbox

HTML cannot require at least one checkbox in a group, so this form checks it on submit and puts the error on the fieldset: the message is linked with aria-describedby and focus goes to the first checkbox. The same idea works for any group rule.

View code
<form class="fv6" novalidate><fieldset aria-describedby="fv6e"><legend>Which topics interest you?</legend><label><input type="checkbox" name="t"> Product updates</label><label><input type="checkbox" name="t"> Design tips</label><label><input type="checkbox" name="t"> Events</label><p id="fv6e" class="e"></p></fieldset><button>Subscribe</button></form>
.fv6 { display: grid; gap: 10px; width: 280px; max-width: 100%; font-family: Inter, system-ui, sans-serif; color: #101828; }
.fv6 fieldset { display: grid; gap: 8px; min-inline-size: 0; margin: 0; padding: 12px 14px; border: 1px solid #d0d5dd; border-radius: 10px; }
.fv6 fieldset.bad { border: 2px solid #d92d20; }
.fv6 legend { padding: 0 4px; font-weight: 700; font-size: 14px; }
.fv6 label { display: flex; gap: 8px; align-items: center; font-size: 14px; }
.fv6 input { width: 18px; height: 18px; margin: 0; accent-color: #1d4ed8; }
.fv6 .e { margin: 0; color: #b42318; font-size: 13px; font-weight: 600; }
.fv6 .e:empty { display: none; }
.fv6 button { padding: 11px; border: 0; border-radius: 8px; background: #1d4ed8; color: #fff; font: 700 14px Inter, system-ui, sans-serif; cursor: pointer; }
.fv6 :focus-visible { outline: 3px solid #93c5fd; outline-offset: 2px; }
var f = document.querySelector('.fv6'), fs = f.querySelector('fieldset'), e = f.querySelector('.e');
f.addEventListener('submit', function (ev) {
  ev.preventDefault();
  var any = f.querySelector('input:checked');
  fs.classList.toggle('bad', !any);
  e.textContent = any ? '' : 'Choose at least one topic.';
  if (!any) f.querySelector('input').focus();
});
f.addEventListener('change', function () { if (f.querySelector('input:checked')) { fs.classList.remove('bad'); e.textContent = ''; } });

07. Gentle Shake on Error

ValidationAnimation

A short horizontal shake draws attention to a wrong code, with the error also stated in text and announced by a live region. The shake is a 300ms keyframe animation that is skipped under reduced motion, where the red border and message are enough.

View code
<form class="fv7" novalidate><label for="fv7c">Enter the 6-digit code</label><input id="fv7c" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="••••••"><p aria-live="assertive"></p><button>Verify</button></form>
.fv7 { display: grid; gap: 8px; width: 260px; max-width: 100%; font-family: Inter, system-ui, sans-serif; color: #e2e8f0; }
.fv7 label { font-weight: 700; font-size: 14px; }
.fv7 input { min-width: 0; padding: 12px; border: 1px solid #475569; border-radius: 10px; background: #111827; color: #fff; font: 600 22px "JetBrains Mono", ui-monospace, monospace; letter-spacing: .4em; text-align: center; }
.fv7 input:focus { outline: none; border-color: #60a5fa; box-shadow: 0 0 0 3px rgba(96,165,250,.3); }
.fv7 input.bad { border-color: #f87171; animation: fv7 .3s ease; }
.fv7 p { margin: 0; min-height: 1.2em; color: #fca5a5; font-size: 13px; }
.fv7 button { padding: 11px; border: 0; border-radius: 10px; background: #2563eb; color: #fff; font: 700 14px Inter, system-ui, sans-serif; cursor: pointer; }
.fv7 button:focus-visible { outline: 3px solid #93c5fd; outline-offset: 2px; }
@keyframes fv7 { 25% { transform: translateX(-6px); } 50% { transform: translateX(6px); } 75% { transform: translateX(-3px); } }
@media (prefers-reduced-motion: reduce) { .fv7 input.bad { animation: none; } }
var f = document.querySelector('.fv7'), i = f.querySelector('input'), p = f.querySelector('p');
f.addEventListener('submit', function (e) {
  e.preventDefault(); i.classList.remove('bad'); void i.offsetWidth;
  if (i.value === '123456') { p.style.color = '#86efac'; p.textContent = 'Code accepted.'; i.removeAttribute('aria-invalid'); return; }
  i.classList.add('bad'); i.setAttribute('aria-invalid', 'true'); p.style.color = ''; p.textContent = 'That code did not match. Try 123456 in this demo.'; i.focus();
});

About these CSS Form Validation

Wait before showing errors

:invalid matches empty required fields as soon as the page loads. :user-invalid and :user-valid wait until the person has edited or left the field, so the form does not start covered in red.

Connect messages to fields

Give each message an id and point to it from the input with aria-describedby, and set aria-invalid="true" on the field. A screen reader then reads the label, the error and the value together.

Summarise long forms

On submit, list every problem in a box at the top, with each item linking to its field, and move focus to the box. Inline messages stay too, for people looking at the field.

Say what to do, not just what is wrong

Enter an email like name@company.com is more useful than Invalid input. Never rely on red alone: add text, an icon or a thicker border.

Frequently asked questions

Do I still need server-side validation?

Yes. Browser validation is for convenience; always validate again on the server.

Which browsers support :user-invalid?

All current major browsers. Older ones ignore the rule, and the script fallback still sets aria-invalid.

Why novalidate on the forms?

It turns off the browser's own bubbles so the custom messages are the only ones shown.