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);
  });
});

React code

Requires: React 19, Tailwind CSS 4

JSX
// Validation states for a text field (error, success, warning, disabled) plus a field that validates as you type.
// Usage: <StatusField status="error" message="Enter a complete email address." label="Email" type="email" defaultValue="ada@example" />
//        <LiveEmailField label="Email address" hint="We send the receipt here." />
import { useState, useId } from 'react';

const caption = 'mb-4 block text-[.72rem] tracking-[.12em] text-[#64748b] uppercase';
const labelCls = 'text-[.82rem] font-semibold text-[#334155]';
const inputBase = 'w-full rounded-[9px] border bg-white px-[14px] py-[11px] text-[.92rem] text-[#0f172a] transition-[border-color,box-shadow] duration-[160ms] focus:outline-none motion-reduce:transition-none';
const STATUS = {
  error: { input: 'border-[#dc2626] focus:shadow-[0_0_0_3px_#fecaca]', message: 'font-semibold text-[#dc2626]' },
  success: { input: 'border-[#16a34a] focus:shadow-[0_0_0_3px_#dbeafe]', message: 'font-semibold text-[#15803d]' },
  warning: { input: 'border-[#d97706] focus:shadow-[0_0_0_3px_#dbeafe]', message: 'font-semibold text-[#b45309]' },
};

// A field in a fixed state. The message is always linked with aria-describedby; errors also set aria-invalid.
export function StatusField({ label, status, message, ...inputProps }) {
  const id = useId();
  const s = STATUS[status];
  return (
    <div className="flex max-w-[360px] flex-col gap-[7px]">
      <label htmlFor={id} className={labelCls}>{label}</label>
      <input id={id} aria-invalid={status === 'error' || undefined} aria-describedby={message ? `${id}-msg` : undefined}
        className={`${inputBase} ${s.input}`} {...inputProps} />
      {message && <span id={`${id}-msg`} className={`text-[.76rem] ${s.message}`}>{message}</span>}
    </div>
  );
}

export function DisabledField({ label, hint, ...inputProps }) {
  const id = useId();
  return (
    <div className="flex max-w-[360px] flex-col gap-[7px]">
      <label htmlFor={id} className={labelCls}>{label}</label>
      <input id={id} disabled aria-describedby={hint ? `${id}-hint` : undefined} className="w-full cursor-not-allowed rounded-[9px] border border-[#8492a6] bg-[#f1f5f9] px-[14px] py-[11px] text-[.92rem] text-[#94a3b8]" {...inputProps} />
      {hint && <small id={`${id}-hint`} className="text-[.76rem] text-[#64748b]">{hint}</small>}
    </div>
  );
}

// Neutral until something is typed, then red or green. The error is announced only while it shows.
export function LiveEmailField({ label = 'Email address', hint, error = 'That is not a valid email address yet.' }) {
  const id = useId();
  const [value, setValue] = useState('');
  const [valid, setValid] = useState(true);
  const bad = value !== '' && !valid;
  const describedBy = [bad && `${id}-err`, hint && `${id}-hint`].filter(Boolean).join(' ') || undefined;
  const tone = value === '' ? 'border-[#8492a6] focus:border-[#2563eb]' : bad ? 'border-[#dc2626]' : 'border-[#16a34a]';
  return (
    <div className="flex max-w-[360px] flex-col gap-[7px]">
      <label htmlFor={id} className={labelCls}>{label} <span className="font-normal text-[#64748b]">(required)</span></label>
      <input id={id} name="email2" type="email" autoComplete="email" required value={value}
        onChange={(e) => { setValue(e.target.value); setValid(e.target.validity.valid); }}
        aria-invalid={bad} aria-describedby={describedBy}
        className={`${inputBase} ${tone} focus:shadow-[0_0_0_3px_#dbeafe]`} />
      {bad && <span id={`${id}-err`} className="text-[.76rem] font-semibold text-[#dc2626]">{error}</span>}
      {hint && <small id={`${id}-hint`} className="text-[.76rem] text-[#64748b]">{hint}</small>}
    </div>
  );
}

export default function ValidationStates() {
  return (
    <>
      <div className="grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-[26px]">
        <div><span className={caption}>Error</span>
          <StatusField status="error" label="Email address" name="email" type="email" autoComplete="email" defaultValue="ada@example"
            message="Enter a complete email address, like [email protected]." /></div>
        <div><span className={caption}>Success</span>
          <StatusField status="success" label="Username" name="username" type="text" autoComplete="username" defaultValue="ada.lovelace"
            message="That username is available." /></div>
        <div><span className={caption}>Warning</span>
          <StatusField status="warning" label="Password" name="password" type="password" autoComplete="new-password" defaultValue="secret123"
            message="This password is weak but usable." /></div>
        <div><span className={caption}>Disabled</span>
          <DisabledField label="Account ID" name="account" type="text" defaultValue="ACC-4471" hint="Assigned automatically." /></div>
      </div>
      <div><span className={caption}>Validates as you type</span>
        <LiveEmailField label="Email address" hint="We send the receipt here." /></div>
    </>
  );
}

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.