Open demo

CSS code

HTML
<div class="grid2">
<div><span class="lbl">Error</span>
<div class="f f-error">
<label for="v1">Email address</label>
<input id="v1" name="email" type="email" autocomplete="email" value="ada@example" aria-invalid="true" aria-describedby="v1-msg">
<span id="v1-msg" class="msg">Enter a complete email address, like [email protected].</span>
</div>
</div>
<div><span class="lbl">Success</span>
<div class="f f-ok">
<label for="v2">Username</label>
<input id="v2" name="username" type="text" autocomplete="username" value="ada.lovelace" aria-describedby="v2-msg">
<span id="v2-msg" class="msg">That username is available.</span>
</div>
</div>
<div><span class="lbl">Warning</span>
<div class="f f-warn">
<label for="v3">Password</label>
<input id="v3" name="password" type="password" autocomplete="new-password" value="secret123" aria-describedby="v3-msg">
<span id="v3-msg" class="msg">This password is weak but usable.</span>
</div>
</div>
<div><span class="lbl">Disabled</span>
<div class="f">
<label for="v4">Account ID</label>
<input id="v4" name="account" type="text" value="ACC-4471" disabled aria-describedby="v4-hint">
<small id="v4-hint">Assigned automatically.</small>
</div>
</div>
</div>
<div><span class="lbl">Validates as you type</span>
<div class="f f-live">
<label for="v5">Email address <span class="req">(required)</span></label>
<input id="v5" name="email2" type="email" autocomplete="email" placeholder=" " required aria-describedby="v5-hint">
<span id="v5-err" class="msg">That is not a valid email address yet.</span>
<small id="v5-hint">We send the receipt here.</small>
</div>
</div>
CSS
/* Validation states. Each message is linked to its input with aria-describedby, and the error
   input carries aria-invalid="true", so the state is announced as well as coloured. */
.f{--f-text:#0f172a;--f-label:#334155;--f-hint:#64748b;--f-bg:#fff;--f-border:#8492a6;--f-focus:#2563eb;
  --f-ring:#dbeafe;--f-radius:9px;--f-error:#dc2626;--f-error-ring:#fecaca;--f-ok:#16a34a;--f-ok-text:#15803d;
  --f-warn:#d97706;--f-warn-text:#b45309;display:flex;flex-direction:column;gap:7px;max-width:360px}
.f label{font-size:.82rem;font-weight:600;color:var(--f-label)}
.f .req{font-weight:400;color:var(--f-hint)}
.f input{box-sizing:border-box;width:100%;font:inherit;font-size:.92rem;padding:11px 14px;color:var(--f-text);
  background:var(--f-bg);border:1px solid var(--f-border);border-radius:var(--f-radius);transition:border-color .16s,box-shadow .16s}
.f input:focus{outline:0;border-color:var(--f-focus);box-shadow:0 0 0 3px var(--f-ring)}
.f small{font-size:.76rem;color:var(--f-hint)}
.f .msg{font-size:.76rem;font-weight:600}
/* error: keyed to aria-invalid so the style and the announced state cannot drift apart */
.f input[aria-invalid="true"]{border-color:var(--f-error)}
.f input[aria-invalid="true"]:focus{box-shadow:0 0 0 3px var(--f-error-ring)}
.f-error .msg{color:var(--f-error)}
.f-ok input,.f-ok input:focus{border-color:var(--f-ok)}
.f-ok .msg{color:var(--f-ok-text)}
.f-warn input,.f-warn input:focus{border-color:var(--f-warn)}
.f-warn .msg{color:var(--f-warn-text)}
.f input:disabled{background:#f1f5f9;color:#94a3b8;cursor:not-allowed}
/* live: neutral while empty, then :invalid / :valid take over once something is typed */
.f-live input:not(:placeholder-shown):invalid{border-color:var(--f-error)}
.f-live input:not(:placeholder-shown):valid{border-color:var(--f-ok)}
.f-live .msg{display:none;color:var(--f-error)}
.f-live input:not(:placeholder-shown):invalid~.msg{display:block}
@media (prefers-reduced-motion:reduce){.f input{transition:none}}
JavaScript
// Mirror the live field's validity to assistive technology: aria-invalid, plus the
// error message in aria-describedby only while it is showing.
document.querySelectorAll('.f-live input').forEach(function (input) {
  var hint = input.getAttribute('aria-describedby');
  var err = input.parentElement.querySelector('.msg').id;
  input.addEventListener('input', function () {
    var bad = input.value !== '' && !input.validity.valid;
    input.setAttribute('aria-invalid', bad ? 'true' : 'false');
    input.setAttribute('aria-describedby', bad ? err + ' ' + hint : hint);
  });
});

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div class="grid2">
<div><span class="lbl">Error</span>
<div class="fx">
<label for="v1" class="form-label">Email address</label>
<input id="v1" name="email" class="form-control is-invalid" type="email" autocomplete="email" value="ada@example" aria-invalid="true" aria-describedby="v1-msg">
<div id="v1-msg" class="invalid-feedback">Enter a complete email address, like [email protected].</div>
</div>
</div>
<div><span class="lbl">Success</span>
<div class="fx">
<label for="v2" class="form-label">Username</label>
<input id="v2" name="username" class="form-control is-valid" type="text" autocomplete="username" value="ada.lovelace" aria-describedby="v2-msg">
<div id="v2-msg" class="valid-feedback">That username is available.</div>
</div>
</div>
<div><span class="lbl">Warning</span>
<div class="fx">
<label for="v3" class="form-label">Password</label>
<input id="v3" name="password" class="form-control is-warning" type="password" autocomplete="new-password" value="secret123" aria-describedby="v3-msg">
<div id="v3-msg" class="warning-feedback">This password is weak but usable.</div>
</div>
</div>
<div><span class="lbl">Disabled</span>
<div class="fx">
<label for="v4" class="form-label">Account ID</label>
<input id="v4" name="account" class="form-control" type="text" value="ACC-4471" disabled aria-describedby="v4-hint">
<div id="v4-hint" class="form-text">Assigned automatically.</div>
</div>
</div>
</div>
<div><span class="lbl">Validates as you type</span>
<div class="fx fx-live">
<label for="v5" class="form-label">Email address <span class="req">(required)</span></label>
<input id="v5" name="email2" class="form-control" type="email" autocomplete="email" placeholder=" " required aria-describedby="v5-hint">
<div id="v5-err" class="invalid-feedback">That is not a valid email address yet.</div>
<div id="v5-hint" class="form-text">We send the receipt here.</div>
</div>
</div>
CSS
/* Bootstrap .is-invalid / .is-valid with .invalid-feedback / .valid-feedback, recoloured through the
   --bs-form-* variables. Bootstrap's in-field icons are switched off to match the plain CSS design. */
.fx{--bs-body-color:#0f172a;--bs-body-bg:#fff;--bs-border-color:#8492a6;--bs-border-radius:9px;
  --bs-secondary-color:#64748b;--bs-secondary-bg:#f1f5f9;
  --bs-form-invalid-color:#dc2626;--bs-form-invalid-border-color:#dc2626;
  --bs-form-valid-color:#15803d;--bs-form-valid-border-color:#16a34a;
  --f-label:#334155;--f-focus:#2563eb;--f-ring:#dbeafe;--f-error-ring:#fecaca;--f-warn:#d97706;--f-warn-text:#b45309;max-width:360px}
.fx .form-label{display:block;margin-bottom:7px;font-size:.82rem;font-weight:600;color:var(--f-label)}
.fx .req{font-weight:400;color:var(--bs-secondary-color)}
.fx .form-control{padding:11px 14px;font-size:.92rem;line-height:normal;transition:border-color .16s,box-shadow .16s}
.fx .form-control,.fx .form-control:focus{background-image:none;padding-right:14px}
.fx .form-control:focus{border-color:var(--f-focus);box-shadow:0 0 0 3px var(--f-ring)}
.fx .form-control.is-invalid:focus{border-color:var(--bs-form-invalid-border-color);box-shadow:0 0 0 3px var(--f-error-ring)}
.fx .form-control.is-valid:focus{border-color:var(--bs-form-valid-border-color);box-shadow:0 0 0 3px var(--f-ring)}
.fx .form-control:disabled{color:#94a3b8;cursor:not-allowed}
.fx .form-text,.fx .invalid-feedback,.fx .valid-feedback,.fx .warning-feedback{margin-top:7px;font-size:.76rem}
.fx .form-text{color:var(--bs-secondary-color)}
.fx .invalid-feedback,.fx .valid-feedback,.fx .warning-feedback{font-weight:600}
/* Bootstrap has no warning state; this adds one in the same shape */
.fx .form-control.is-warning,.fx .form-control.is-warning:focus{border-color:var(--f-warn)}
.fx .warning-feedback{color:var(--f-warn-text)}
@media (prefers-reduced-motion:reduce){.fx .form-control{transition:none}}
JavaScript
// Live validation the Bootstrap way: toggle .is-invalid / .is-valid from the field's validity,
// neutral while empty, and describe the input with the error only while it shows.
document.querySelectorAll('.fx-live .form-control').forEach(function (input) {
  var hint = input.getAttribute('aria-describedby');
  var err = input.parentElement.querySelector('.invalid-feedback').id;
  input.addEventListener('input', function () {
    var empty = input.value === '', bad = !empty && !input.validity.valid;
    input.classList.toggle('is-invalid', bad);
    input.classList.toggle('is-valid', !empty && !bad);
    input.setAttribute('aria-invalid', bad ? 'true' : 'false');
    input.setAttribute('aria-describedby', bad ? err + ' ' + hint : hint);
  });
});

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div class="grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-[26px]">
<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#64748b] uppercase">Error</span>
<div class="flex max-w-[360px] flex-col gap-[7px]">
<label for="v1" class="text-[.82rem] font-semibold text-[#334155]">Email address</label>
<input id="v1" name="email" type="email" autocomplete="email" value="ada@example" aria-invalid="true" aria-describedby="v1-msg" class="w-full rounded-[9px] border border-[#dc2626] bg-white px-[14px] py-[11px] text-[.92rem] text-[#0f172a] transition-[border-color,box-shadow] duration-[160ms] focus:shadow-[0_0_0_3px_#fecaca] focus:outline-none motion-reduce:transition-none">
<span id="v1-msg" class="text-[.76rem] font-semibold text-[#dc2626]">Enter a complete email address, like [email protected].</span>
</div>
</div>
<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#64748b] uppercase">Success</span>
<div class="flex max-w-[360px] flex-col gap-[7px]">
<label for="v2" class="text-[.82rem] font-semibold text-[#334155]">Username</label>
<input id="v2" name="username" type="text" autocomplete="username" value="ada.lovelace" aria-describedby="v2-msg" class="w-full rounded-[9px] border border-[#16a34a] bg-white px-[14px] py-[11px] text-[.92rem] text-[#0f172a] transition-[border-color,box-shadow] duration-[160ms] focus:shadow-[0_0_0_3px_#dbeafe] focus:outline-none motion-reduce:transition-none">
<span id="v2-msg" class="text-[.76rem] font-semibold text-[#15803d]">That username is available.</span>
</div>
</div>
<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#64748b] uppercase">Warning</span>
<div class="flex max-w-[360px] flex-col gap-[7px]">
<label for="v3" class="text-[.82rem] font-semibold text-[#334155]">Password</label>
<input id="v3" name="password" type="password" autocomplete="new-password" value="secret123" aria-describedby="v3-msg" class="w-full rounded-[9px] border border-[#d97706] bg-white px-[14px] py-[11px] text-[.92rem] text-[#0f172a] transition-[border-color,box-shadow] duration-[160ms] focus:shadow-[0_0_0_3px_#dbeafe] focus:outline-none motion-reduce:transition-none">
<span id="v3-msg" class="text-[.76rem] font-semibold text-[#b45309]">This password is weak but usable.</span>
</div>
</div>
<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#64748b] uppercase">Disabled</span>
<div class="flex max-w-[360px] flex-col gap-[7px]">
<label for="v4" class="text-[.82rem] font-semibold text-[#334155]">Account ID</label>
<input id="v4" name="account" type="text" value="ACC-4471" disabled aria-describedby="v4-hint" class="w-full cursor-not-allowed rounded-[9px] border border-[#8492a6] bg-[#f1f5f9] px-[14px] py-[11px] text-[.92rem] text-[#94a3b8]">
<small id="v4-hint" class="text-[.76rem] text-[#64748b]">Assigned automatically.</small>
</div>
</div>
</div>
<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#64748b] uppercase">Validates as you type</span>
<div class="flex max-w-[360px] flex-col gap-[7px]">
<label for="v5" class="text-[.82rem] font-semibold text-[#334155]">Email address <span class="font-normal text-[#64748b]">(required)</span></label>
<input id="v5" name="email2" type="email" autocomplete="email" placeholder=" " required aria-describedby="v5-hint" data-live="v5-err" class="peer w-full rounded-[9px] border border-[#8492a6] bg-white px-[14px] py-[11px] text-[.92rem] text-[#0f172a] transition-[border-color,box-shadow] duration-[160ms] focus:border-[#2563eb] focus:shadow-[0_0_0_3px_#dbeafe] focus:outline-none motion-reduce:transition-none [&:not(:placeholder-shown):invalid]:border-[#dc2626] [&:not(:placeholder-shown):valid]:border-[#16a34a]">
<span id="v5-err" class="hidden text-[.76rem] font-semibold text-[#dc2626] peer-[:not(:placeholder-shown):invalid]:block">That is not a valid email address yet.</span>
<small id="v5-hint" class="text-[.76rem] text-[#64748b]">We send the receipt here.</small>
</div>
</div>
JavaScript
// Mirror the live field's validity to assistive technology: aria-invalid, plus the
// error message in aria-describedby only while it is showing.
document.querySelectorAll('input[data-live]').forEach(function (input) {
  var hint = input.getAttribute('aria-describedby'), err = input.dataset.live;
  input.addEventListener('input', function () {
    var bad = input.value !== '' && !input.validity.valid;
    input.setAttribute('aria-invalid', bad ? 'true' : 'false');
    input.setAttribute('aria-describedby', bad ? err + ' ' + hint : hint);
  });
});

About this set

The five field states a real form needs, each with its message in words so the state never relies on colour alone. Error shows a red border and a message that says what to fix; the input carries aria-invalid=”true” and the plain CSS styles that attribute, so the look and the announced state cannot drift apart. Success shows a green border and a confirmation, warning an amber border and advice, and disabled a greyed field with a hint. The last field validates as you type: it stays neutral while empty, then turns red while the email address is incomplete and green once it is valid. The colours come from :invalid and :valid combined with :not(:placeholder-shown), and a few lines of script copy the state to aria-invalid and add the error to aria-describedby only while it is showing. Use the static states for server-side results and the live field where early feedback helps. The Bootstrap version uses .is-invalid, .is-valid, .invalid-feedback and .valid-feedback with its in-field icons switched off, plus a small .is-warning addition; its live field toggles those classes from the browser’s own validity.

What’s included

  • Error, success, warning, disabled and live states
  • Every message linked to its input with aria-describedby
  • Error styling keyed to aria-invalid in the plain CSS version
  • Live field stays neutral until something is typed
  • Bootstrap version uses .is-invalid, .is-valid and the feedback classes

Accessibility

Each message has an id and is referenced from its input's aria-describedby, so screen readers read it with the field; the error field is marked aria-invalid, and the live field gains and loses aria-invalid and its error description as the value changes. Message text reaches 4.5:1 and state borders 3:1 against the page. The required field says (required) in its label. Focus adds a 3px ring, pink on the error field, and border transitions are removed under prefers-reduced-motion.

Customise it

In plain CSS change --f-error, --f-ok, --f-ok-text, --f-warn and --f-warn-text on .f. In Bootstrap set --bs-form-invalid-color, --bs-form-invalid-border-color, --bs-form-valid-color and --bs-form-valid-border-color on .fx; in Tailwind edit the border- and text- colour classes.