Open demo

CSS code

HTML
<div class="demo-grid">
<div><span class="lbl">Errors only after interaction</span>
<form class="mv" action="#" method="post" aria-labelledby="pl-title">
  <div class="mv-brand"><span class="mv-mark"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20v-8M12 12c0-4-3-6-7-6 0 4 3 6 7 6zM12 15c0-3 2-5 6-5 0 3-2 5-6 5zM4 20h16"/></svg></span>Plotline Allotments</div>
  <h2 class="mv-title" id="pl-title">Apply for a plot</h2>
  <p class="mv-sub">Join the waiting list for a half plot at one of our four sites. Fields marked <span class="mv-req">*</span> are required.</p>
  <div class="mv-field">
    <label class="mv-label" for="pl-name">Full name <span class="mv-req" aria-hidden="true">*</span></label>
    <input class="mv-input" id="pl-name" name="name" type="text" autocomplete="name" required minlength="2" aria-describedby="pl-name-msg">
    <p class="mv-msg" id="pl-name-msg">Enter your full name.</p>
  </div>
  <div class="mv-field">
    <label class="mv-label" for="pl-email">Email <span class="mv-req" aria-hidden="true">*</span></label>
    <input class="mv-input" id="pl-email" name="email" type="email" autocomplete="email" required aria-describedby="pl-email-msg">
    <p class="mv-msg" id="pl-email-msg">Enter an email address, like [email protected].</p>
  </div>
  <div class="mv-field">
    <label class="mv-label" for="pl-postcode">Postcode <span class="mv-req" aria-hidden="true">*</span></label>
    <input class="mv-input" id="pl-postcode" name="postal-code" type="text" autocomplete="postal-code" required
      pattern="[A-Za-z]{1,2}[0-9][A-Za-z0-9]? ?[0-9][A-Za-z]{2}" aria-describedby="pl-postcode-msg pl-postcode-extra">
    <p class="mv-msg" id="pl-postcode-msg">Enter a UK postcode, like LS6 2QT.</p>
    <p class="mv-extra" id="pl-postcode-extra">We use it to offer you the nearest site first.</p>
  </div>
  <div class="mv-field">
    <label class="mv-label" for="pl-tel">Phone (optional)</label>
    <input class="mv-input" id="pl-tel" name="tel" type="tel" autocomplete="tel" minlength="10" aria-describedby="pl-tel-msg">
    <p class="mv-msg" id="pl-tel-msg">At least 10 digits, for when a plot comes up.</p>
  </div>
  <button class="mv-btn" type="submit">Join the waiting list</button>
</form>
</div>
<div><span class="lbl">Progress and reveal with :has()</span>
<form class="mv mv--blue" action="#" method="post" aria-labelledby="rw-title">
  <div class="mv-brand"><span class="mv-mark"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 17c3 2 6 2 9 0s6-2 9 0M5 13L19 5M15 5h4v4"/></svg></span>Tidewater Rowing Club</div>
  <h2 class="mv-title" id="rw-title">Learn to Row, spring course</h2>
  <p class="mv-sub">Six Saturday mornings on the water from 7 March 2026. Every field is required unless it says optional.</p>
  <div class="mv-field">
    <label class="mv-label" for="rw-name">Full name</label>
    <input class="mv-input" id="rw-name" name="name" type="text" autocomplete="name" required minlength="2" aria-describedby="rw-name-msg">
    <p class="mv-msg" id="rw-name-msg">Enter your full name.</p>
  </div>
  <div class="mv-field">
    <label class="mv-label" for="rw-email">Email</label>
    <input class="mv-input" id="rw-email" name="email" type="email" autocomplete="email" required aria-describedby="rw-email-msg">
    <p class="mv-msg" id="rw-email-msg">Enter an email address, like [email protected].</p>
  </div>
  <div class="mv-field">
    <label class="mv-label" for="rw-dob">Date of birth</label>
    <input class="mv-input" id="rw-dob" name="bday" type="date" autocomplete="bday" required min="1930-01-01" max="2010-03-07" aria-describedby="rw-dob-msg">
    <p class="mv-msg" id="rw-dob-msg">You need to be 16 or over on 7 March 2026.</p>
  </div>
  <div class="mv-field">
    <label class="mv-check"><input id="rw-swim" name="swim" type="checkbox" required aria-describedby="rw-swim-msg"><span>I can swim 50 metres in light clothing</span></label>
    <p class="mv-msg" id="rw-swim-msg">Everyone on the water has to be able to.</p>
  </div>
  <div class="mv-field">
    <label class="mv-check"><input class="mv-toggle" name="rowed-before" type="checkbox"><span>I have rowed before (optional)</span></label>
    <div class="mv-more">
      <label class="mv-label" for="rw-exp">Where and for how long? (optional)</label>
      <textarea class="mv-input" id="rw-exp" name="experience" rows="2"></textarea>
    </div>
  </div>
  <div class="mv-progress" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
  <p class="mv-ready" aria-hidden="true"><span>Fill in the four required fields to book.</span><span>All set. You can book your place.</span></p>
  <button class="mv-btn" type="submit">Book my place, £85</button>
</form>
</div>
</div>
CSS
/* Validation with CSS only. :user-invalid and :user-valid wait until someone has edited a field or tried to submit,
   so a fresh form shows no red. Each field's message is its requirement, linked with aria-describedby, and turns red
   with a "!" when the field is invalid. :has() lights the progress segments, reveals optional fields and switches the
   submit button's look; the button is never disabled, so submitting still runs the browser's own checks. No JavaScript. */
.mv{--mv-accent:#0f766e;--mv-accent-dark:#115e59;--mv-ring:rgba(15,118,110,.22);--mv-ink:#10201c;--mv-muted:#4b5c58;--mv-line:#788884;--mv-ok:#15803d;--mv-error:#b91c1c;
  --mv-idle-bg:#d5dedb;--mv-idle-ink:#28403a;
  background:#fff;color:var(--mv-ink);border-radius:20px;padding:32px 30px 28px;font-size:.92rem;line-height:1.45;
  box-shadow:0 1px 2px rgba(16,32,28,.06),0 16px 36px -16px rgba(16,32,28,.25)}
.mv--blue{--mv-accent:#1d4ed8;--mv-accent-dark:#1e40af;--mv-ring:rgba(29,78,216,.22)}
.mv-brand{display:flex;align-items:center;gap:10px;font-weight:700;margin-bottom:20px}
.mv-mark{width:32px;height:32px;border-radius:9px;background:var(--mv-accent);color:#fff;display:grid;place-items:center}
.mv-title{font-size:1.4rem;font-weight:750;letter-spacing:-.02em;line-height:1.2}
.mv-sub{color:var(--mv-muted);margin:6px 0 20px}
.mv-req{color:var(--mv-accent);font-weight:700}
.mv-field{margin-bottom:16px;min-width:0}
.mv-label{display:block;font-size:.84rem;font-weight:600;margin-bottom:6px}
.mv-input{display:block;width:100%;font:inherit;color:inherit;background:#fff no-repeat right 12px center/18px;border:1px solid var(--mv-line);border-radius:10px;
  padding:11px 40px 11px 13px;transition:border-color .15s,box-shadow .15s}
textarea.mv-input{min-height:80px;resize:vertical;padding-right:13px}
.mv-input:focus{outline:none;border-color:var(--mv-accent);box-shadow:0 0 0 3px var(--mv-ring)}
.mv-msg{display:flex;gap:6px;align-items:flex-start;font-size:.8rem;color:var(--mv-muted);margin-top:6px}
/* states after interaction */
.mv-input:user-valid{border-color:var(--mv-ok);
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M5 12.5l4.5 4.5L19 7.5' fill='none' stroke='%2315803d' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")}
.mv-input[type=date]{padding-right:13px}
.mv-input[type=date]:user-valid{background-image:none}
.mv-input:user-invalid{border-color:var(--mv-error)}
.mv-input:user-invalid:focus{box-shadow:0 0 0 3px rgba(185,28,28,.2)}
.mv-field:has(:user-invalid) .mv-msg{color:var(--mv-error);font-weight:600}
.mv-field:has(:user-invalid) .mv-msg::before{content:"!";flex:none;width:16px;height:16px;margin-top:1px;border-radius:50%;background:var(--mv-error);color:#fff;
  font-size:.7rem;font-weight:800;display:grid;place-items:center}
/* a hint that only opens while its field has focus */
.mv-extra{display:none;font-size:.8rem;color:var(--mv-muted);margin-top:6px;padding:8px 10px;border-radius:8px;background:#eef6f4}
.mv-field:has(:focus) .mv-extra{display:block}
/* checkbox */
.mv-check{display:flex;gap:10px;align-items:flex-start;font-size:.86rem;cursor:pointer}
.mv-check input{appearance:none;flex:none;width:18px;height:18px;margin-top:1px;border:1.5px solid var(--mv-line);border-radius:5px;background:#fff center/12px no-repeat;cursor:pointer;
  transition:background-color .15s,border-color .15s}
.mv-check input:checked{background-color:var(--mv-accent);border-color:var(--mv-accent);
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.5l3 3 6-7' fill='none' stroke='%23fff' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")}
.mv-check input:focus-visible{outline:2px solid var(--mv-accent);outline-offset:2px}
.mv-check input:user-invalid{border-color:var(--mv-error)}
.mv-check+.mv-msg{margin-left:28px}
/* optional section revealed by a checkbox */
.mv-more{display:none;margin-top:12px}
.mv:has(.mv-toggle:checked) .mv-more{display:block}
/* progress: one segment per required field */
.mv-progress{display:grid;grid-template-columns:repeat(4,1fr);gap:5px;margin:20px 0 8px}
.mv-progress i{height:6px;border-radius:3px;background:var(--mv-idle-bg);transition:background-color .2s}
.mv:has(#rw-name:valid) .mv-progress i:nth-child(1),.mv:has(#rw-email:valid) .mv-progress i:nth-child(2),
.mv:has(#rw-dob:valid) .mv-progress i:nth-child(3),.mv:has(#rw-swim:checked) .mv-progress i:nth-child(4){background:var(--mv-ok)}
.mv-ready{font-size:.8rem;color:var(--mv-muted);margin-bottom:12px}
.mv-ready span:last-child,.mv:not(:has(:invalid)) .mv-ready span:first-child{display:none}
.mv:not(:has(:invalid)) .mv-ready span:last-child{display:inline;color:var(--mv-ok);font-weight:600}
/* submit button: neutral until every field is valid, but never disabled */
.mv-btn{display:block;width:100%;font:inherit;font-weight:650;border:0;border-radius:10px;padding:12px 16px;cursor:pointer;margin-top:18px;
  color:var(--mv-idle-ink);background:var(--mv-idle-bg);transition:background-color .2s,color .2s}
.mv:not(:has(:invalid)) .mv-btn{color:#fff;background:var(--mv-accent)}
.mv:not(:has(:invalid)) .mv-btn:hover{background:var(--mv-accent-dark)}
.mv-btn:focus-visible{outline:2px solid var(--mv-accent);outline-offset:3px}
.mv-progress+.mv-ready+.mv-btn{margin-top:0}
@media (max-width:420px){.mv{padding:26px 20px 22px}}
@media (prefers-reduced-motion:reduce){.mv-input,.mv-check input,.mv-progress i,.mv-btn{transition:none}}

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div class="demo-grid">
<div><span class="lbl">Errors only after interaction</span>
<form class="card mv" action="#" method="post" aria-labelledby="pl-title">
  <div class="mv-brand"><span class="mv-mark"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20v-8M12 12c0-4-3-6-7-6 0 4 3 6 7 6zM12 15c0-3 2-5 6-5 0 3-2 5-6 5zM4 20h16"/></svg></span>Plotline Allotments</div>
  <h2 class="mv-title" id="pl-title">Apply for a plot</h2>
  <p class="mv-sub">Join the waiting list for a half plot at one of our four sites. Fields marked <span class="mv-req">*</span> are required.</p>
  <div class="mb-3 mv-field">
    <label class="form-label" for="pl-name">Full name <span class="mv-req" aria-hidden="true">*</span></label>
    <input class="form-control" id="pl-name" name="name" type="text" autocomplete="name" required minlength="2" aria-describedby="pl-name-msg">
    <p class="form-text" id="pl-name-msg">Enter your full name.</p>
  </div>
  <div class="mb-3 mv-field">
    <label class="form-label" for="pl-email">Email <span class="mv-req" aria-hidden="true">*</span></label>
    <input class="form-control" id="pl-email" name="email" type="email" autocomplete="email" required aria-describedby="pl-email-msg">
    <p class="form-text" id="pl-email-msg">Enter an email address, like [email protected].</p>
  </div>
  <div class="mb-3 mv-field">
    <label class="form-label" for="pl-postcode">Postcode <span class="mv-req" aria-hidden="true">*</span></label>
    <input class="form-control" id="pl-postcode" name="postal-code" type="text" autocomplete="postal-code" required
      pattern="[A-Za-z]{1,2}[0-9][A-Za-z0-9]? ?[0-9][A-Za-z]{2}" aria-describedby="pl-postcode-msg pl-postcode-extra">
    <p class="form-text" id="pl-postcode-msg">Enter a UK postcode, like LS6 2QT.</p>
    <p class="mv-extra" id="pl-postcode-extra">We use it to offer you the nearest site first.</p>
  </div>
  <div class="mb-3 mv-field">
    <label class="form-label" for="pl-tel">Phone (optional)</label>
    <input class="form-control" id="pl-tel" name="tel" type="tel" autocomplete="tel" minlength="10" aria-describedby="pl-tel-msg">
    <p class="form-text" id="pl-tel-msg">At least 10 digits, for when a plot comes up.</p>
  </div>
  <button class="btn mv-btn w-100" type="submit">Join the waiting list</button>
</form>
</div>
<div><span class="lbl">Progress and reveal with :has()</span>
<form class="card mv mv--blue" action="#" method="post" aria-labelledby="rw-title">
  <div class="mv-brand"><span class="mv-mark"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 17c3 2 6 2 9 0s6-2 9 0M5 13L19 5M15 5h4v4"/></svg></span>Tidewater Rowing Club</div>
  <h2 class="mv-title" id="rw-title">Learn to Row, spring course</h2>
  <p class="mv-sub">Six Saturday mornings on the water from 7 March 2026. Every field is required unless it says optional.</p>
  <div class="mb-3 mv-field">
    <label class="form-label" for="rw-name">Full name</label>
    <input class="form-control" id="rw-name" name="name" type="text" autocomplete="name" required minlength="2" aria-describedby="rw-name-msg">
    <p class="form-text" id="rw-name-msg">Enter your full name.</p>
  </div>
  <div class="mb-3 mv-field">
    <label class="form-label" for="rw-email">Email</label>
    <input class="form-control" id="rw-email" name="email" type="email" autocomplete="email" required aria-describedby="rw-email-msg">
    <p class="form-text" id="rw-email-msg">Enter an email address, like [email protected].</p>
  </div>
  <div class="mb-3 mv-field">
    <label class="form-label" for="rw-dob">Date of birth</label>
    <input class="form-control" id="rw-dob" name="bday" type="date" autocomplete="bday" required min="1930-01-01" max="2010-03-07" aria-describedby="rw-dob-msg">
    <p class="form-text" id="rw-dob-msg">You need to be 16 or over on 7 March 2026.</p>
  </div>
  <div class="mb-3 mv-field">
    <div class="form-check"><input class="form-check-input" id="rw-swim" name="swim" type="checkbox" required aria-describedby="rw-swim-msg"><label class="form-check-label" for="rw-swim">I can swim 50 metres in light clothing</label></div>
    <p class="form-text" id="rw-swim-msg">Everyone on the water has to be able to.</p>
  </div>
  <div class="mb-3 mv-field">
    <div class="form-check"><input class="form-check-input mv-toggle" id="rw-before" name="rowed-before" type="checkbox"><label class="form-check-label" for="rw-before">I have rowed before (optional)</label></div>
    <div class="mv-more">
      <label class="form-label" for="rw-exp">Where and for how long? (optional)</label>
      <textarea class="form-control" id="rw-exp" name="experience" rows="2"></textarea>
    </div>
  </div>
  <div class="mv-progress" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
  <p class="mv-ready" aria-hidden="true"><span>Fill in the four required fields to book.</span><span>All set. You can book your place.</span></p>
  <button class="btn mv-btn w-100" type="submit">Book my place, £85</button>
</form>
</div>
</div>
CSS
/* Bootstrap .card, .form-control, .form-text, .form-check and .btn, with validation styled by :user-invalid and
   :user-valid instead of .was-validated, so a fresh form shows no red and no script is needed. Each .form-text is the
   field's requirement, linked with aria-describedby, and turns red with a "!" when the field is invalid. :has() lights
   the progress segments, reveals optional fields and switches the button's look; the button is never disabled. */
.mv{--mv-accent:#0f766e;--mv-accent-dark:#115e59;--mv-ring:rgba(15,118,110,.22);--mv-ink:#10201c;--mv-muted:#4b5c58;--mv-line:#788884;--mv-ok:#15803d;--mv-error:#b91c1c;
  --mv-idle-bg:#d5dedb;--mv-idle-ink:#28403a;
  --bs-card-border-width:0;--bs-card-border-radius:20px;--bs-border-color:var(--mv-line);--bs-border-radius:10px;--bs-body-color:var(--mv-ink);
  padding:32px 30px 28px;color:var(--mv-ink);font-size:.92rem;line-height:1.45;box-shadow:0 1px 2px rgba(16,32,28,.06),0 16px 36px -16px rgba(16,32,28,.25)}
.mv--blue{--mv-accent:#1d4ed8;--mv-accent-dark:#1e40af;--mv-ring:rgba(29,78,216,.22)}
.mv p,.mv h2{margin:0}
.mv .mv-brand{display:flex;align-items:center;gap:10px;font-weight:700;margin-bottom:20px}
.mv .mv-mark{width:32px;height:32px;border-radius:9px;background:var(--mv-accent);color:#fff;display:grid;place-items:center}
.mv .mv-title{font-size:1.4rem;font-weight:750;letter-spacing:-.02em;line-height:1.2}
.mv .mv-sub{color:var(--mv-muted);margin:6px 0 20px}
.mv .mv-req{color:var(--mv-accent);font-weight:700}
.mv .form-label{display:block;font-size:.84rem;font-weight:600;margin-bottom:6px}
.mv .form-control{font-size:inherit;line-height:inherit;color:var(--mv-ink);padding:11px 40px 11px 13px;background:#fff no-repeat right 12px center/18px;
  transition:border-color .15s,box-shadow .15s}
.mv textarea.form-control{min-height:80px;padding-right:13px}
.mv .form-control[type=date]{padding-right:13px}
.mv .form-control:focus{border-color:var(--mv-accent);box-shadow:0 0 0 3px var(--mv-ring)}
.mv .form-text{display:flex;gap:6px;align-items:flex-start;font-size:.8rem;color:var(--mv-muted);margin-top:6px}
/* states after interaction */
.mv .form-control:user-valid{border-color:var(--mv-ok);
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M5 12.5l4.5 4.5L19 7.5' fill='none' stroke='%2315803d' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")}
.mv .form-control[type=date]:user-valid{background-image:none}
.mv .form-control:user-invalid{border-color:var(--mv-error)}
.mv .form-control:user-invalid:focus{box-shadow:0 0 0 3px rgba(185,28,28,.2)}
.mv .mv-field:has(:user-invalid) .form-text{color:var(--mv-error);font-weight:600}
.mv .mv-field:has(:user-invalid) .form-text::before{content:"!";flex:none;width:16px;height:16px;margin-top:1px;border-radius:50%;background:var(--mv-error);color:#fff;
  font-size:.7rem;font-weight:800;display:grid;place-items:center}
/* a hint that only opens while its field has focus */
.mv .mv-extra{display:none;font-size:.8rem;color:var(--mv-muted);margin-top:6px;padding:8px 10px;border-radius:8px;background:#eef6f4}
.mv .mv-field:has(:focus) .mv-extra{display:block}
/* checkbox */
.mv .form-check{display:flex;gap:10px;align-items:flex-start;padding:0;margin:0;min-height:0;font-size:.86rem}
.mv .form-check-input{float:none;flex:none;width:18px;height:18px;margin:1px 0 0;border:1.5px solid var(--mv-line);border-radius:5px;cursor:pointer;
  --bs-form-check-bg-image:none;transition:background-color .15s,border-color .15s}
.mv .form-check-input:checked{background-color:var(--mv-accent);border-color:var(--mv-accent);background-size:12px;
  --bs-form-check-bg-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.5l3 3 6-7' fill='none' stroke='%23fff' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")}
.mv .form-check-input:focus{box-shadow:none}
.mv .form-check-input:focus-visible{outline:2px solid var(--mv-accent);outline-offset:2px}
.mv .form-check-input:user-invalid{border-color:var(--mv-error)}
.mv .form-check-label{cursor:pointer}
.mv .form-check+.form-text{margin-left:28px}
/* optional section revealed by a checkbox */
.mv .mv-more{display:none;margin-top:12px}
.mv:has(.mv-toggle:checked) .mv-more{display:block}
/* progress: one segment per required field */
.mv .mv-progress{display:grid;grid-template-columns:repeat(4,1fr);gap:5px;margin:20px 0 8px}
.mv .mv-progress i{height:6px;border-radius:3px;background:var(--mv-idle-bg);transition:background-color .2s}
.mv:has(#rw-name:valid) .mv-progress i:nth-child(1),.mv:has(#rw-email:valid) .mv-progress i:nth-child(2),
.mv:has(#rw-dob:valid) .mv-progress i:nth-child(3),.mv:has(#rw-swim:checked) .mv-progress i:nth-child(4){background:var(--mv-ok)}
.mv .mv-ready{font-size:.8rem;color:var(--mv-muted);margin-bottom:12px}
.mv .mv-ready span:last-child,.mv:not(:has(:invalid)) .mv-ready span:first-child{display:none}
.mv:not(:has(:invalid)) .mv-ready span:last-child{display:inline;color:var(--mv-ok);font-weight:600}
/* submit button: neutral until every field is valid, but never disabled */
.mv .mv-btn{--bs-btn-color:var(--mv-idle-ink);--bs-btn-bg:var(--mv-idle-bg);--bs-btn-border-color:var(--mv-idle-bg);--bs-btn-hover-color:var(--mv-idle-ink);
  --bs-btn-hover-bg:var(--mv-idle-bg);--bs-btn-hover-border-color:var(--mv-idle-bg);--bs-btn-active-color:var(--mv-idle-ink);--bs-btn-active-bg:var(--mv-idle-bg);
  --bs-btn-active-border-color:var(--mv-idle-bg);--bs-btn-focus-box-shadow:none;--bs-btn-font-weight:650;--bs-btn-font-size:.92rem;--bs-btn-padding-y:11px;
  --bs-btn-border-radius:10px;line-height:1.45;margin-top:18px;display:block;transition:background-color .2s,color .2s}
.mv:not(:has(:invalid)) .mv-btn{--bs-btn-color:#fff;--bs-btn-bg:var(--mv-accent);--bs-btn-border-color:var(--mv-accent);--bs-btn-hover-color:#fff;
  --bs-btn-hover-bg:var(--mv-accent-dark);--bs-btn-hover-border-color:var(--mv-accent-dark);--bs-btn-active-color:#fff;--bs-btn-active-bg:var(--mv-accent-dark)}
.mv .mv-btn:focus-visible{outline:2px solid var(--mv-accent);outline-offset:3px}
.mv .mv-ready+.mv-btn{margin-top:0}
.card.mv{display:block} /* plain block flow, so vertical margins match the CSS version */
@media (max-width:420px){.mv{padding:26px 20px 22px}}
@media (prefers-reduced-motion:reduce){.mv .form-control,.mv .form-check-input,.mv .mv-progress i,.mv .btn{transition:none}}

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div class="grid grid-cols-[repeat(auto-fit,minmax(300px,1fr))] items-start gap-[34px]">
<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#4d5f5a] uppercase">Errors only after interaction</span>
<form class="group/form rounded-[20px] bg-white px-[30px] pt-8 pb-7 text-[.92rem] leading-[1.45] text-mv-ink shadow-[0_1px_2px_rgba(16,32,28,.06),0_16px_36px_-16px_rgba(16,32,28,.25)] max-[420px]:px-5 max-[420px]:pt-[26px] max-[420px]:pb-[22px] [--mv-accent:#0f766e] [--mv-accent-dark:#115e59] [--mv-ring:rgba(15,118,110,.22)]" action="#" method="post" aria-labelledby="pl-title">
  <div class="mb-5 flex items-center gap-2.5 font-bold"><span class="grid size-8 place-items-center rounded-[9px] bg-(--mv-accent) text-white"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20v-8M12 12c0-4-3-6-7-6 0 4 3 6 7 6zM12 15c0-3 2-5 6-5 0 3-2 5-6 5zM4 20h16"/></svg></span>Plotline Allotments</div>
  <h2 class="text-[1.4rem] leading-[1.2] font-[750] tracking-[-.02em]" id="pl-title">Apply for a plot</h2>
  <p class="mt-1.5 mb-5 text-mv-muted">Join the waiting list for a half plot at one of our four sites. Fields marked <span class="font-bold text-(--mv-accent)">*</span> are required.</p>
  <div class="group mb-4 min-w-0">
    <label class="mb-1.5 block text-[.84rem] font-semibold" for="pl-name">Full name <span class="font-bold text-(--mv-accent)" aria-hidden="true">*</span></label>
    <input class="block w-full rounded-[10px] border border-mv-line bg-white bg-[length:18px] bg-[right_12px_center] bg-no-repeat py-[11px] pl-[13px] transition-[border-color,box-shadow] duration-150 focus:border-(--mv-accent) focus:shadow-[0_0_0_3px_var(--mv-ring)] focus:outline-none user-valid:border-mv-ok user-invalid:border-mv-error user-invalid:focus:shadow-[0_0_0_3px_rgba(185,28,28,.2)] motion-reduce:transition-none pr-10 user-valid:mv-valid-tick" id="pl-name" name="name" type="text" autocomplete="name" required minlength="2" aria-describedby="pl-name-msg">
    <p class="mt-1.5 flex items-start gap-1.5 text-[.8rem] text-mv-muted group-has-[:user-invalid]:font-semibold group-has-[:user-invalid]:text-mv-error group-has-[:user-invalid]:before:mt-px group-has-[:user-invalid]:before:grid group-has-[:user-invalid]:before:size-4 group-has-[:user-invalid]:before:flex-none group-has-[:user-invalid]:before:place-items-center group-has-[:user-invalid]:before:rounded-full group-has-[:user-invalid]:before:bg-mv-error group-has-[:user-invalid]:before:text-[.7rem] group-has-[:user-invalid]:before:font-extrabold group-has-[:user-invalid]:before:text-white group-has-[:user-invalid]:before:content-['!']" id="pl-name-msg">Enter your full name.</p>
  </div>
  <div class="group mb-4 min-w-0">
    <label class="mb-1.5 block text-[.84rem] font-semibold" for="pl-email">Email <span class="font-bold text-(--mv-accent)" aria-hidden="true">*</span></label>
    <input class="block w-full rounded-[10px] border border-mv-line bg-white bg-[length:18px] bg-[right_12px_center] bg-no-repeat py-[11px] pl-[13px] transition-[border-color,box-shadow] duration-150 focus:border-(--mv-accent) focus:shadow-[0_0_0_3px_var(--mv-ring)] focus:outline-none user-valid:border-mv-ok user-invalid:border-mv-error user-invalid:focus:shadow-[0_0_0_3px_rgba(185,28,28,.2)] motion-reduce:transition-none pr-10 user-valid:mv-valid-tick" id="pl-email" name="email" type="email" autocomplete="email" required aria-describedby="pl-email-msg">
    <p class="mt-1.5 flex items-start gap-1.5 text-[.8rem] text-mv-muted group-has-[:user-invalid]:font-semibold group-has-[:user-invalid]:text-mv-error group-has-[:user-invalid]:before:mt-px group-has-[:user-invalid]:before:grid group-has-[:user-invalid]:before:size-4 group-has-[:user-invalid]:before:flex-none group-has-[:user-invalid]:before:place-items-center group-has-[:user-invalid]:before:rounded-full group-has-[:user-invalid]:before:bg-mv-error group-has-[:user-invalid]:before:text-[.7rem] group-has-[:user-invalid]:before:font-extrabold group-has-[:user-invalid]:before:text-white group-has-[:user-invalid]:before:content-['!']" id="pl-email-msg">Enter an email address, like [email protected].</p>
  </div>
  <div class="group mb-4 min-w-0">
    <label class="mb-1.5 block text-[.84rem] font-semibold" for="pl-postcode">Postcode <span class="font-bold text-(--mv-accent)" aria-hidden="true">*</span></label>
    <input class="block w-full rounded-[10px] border border-mv-line bg-white bg-[length:18px] bg-[right_12px_center] bg-no-repeat py-[11px] pl-[13px] transition-[border-color,box-shadow] duration-150 focus:border-(--mv-accent) focus:shadow-[0_0_0_3px_var(--mv-ring)] focus:outline-none user-valid:border-mv-ok user-invalid:border-mv-error user-invalid:focus:shadow-[0_0_0_3px_rgba(185,28,28,.2)] motion-reduce:transition-none pr-10 user-valid:mv-valid-tick" id="pl-postcode" name="postal-code" type="text" autocomplete="postal-code" required
      pattern="[A-Za-z]{1,2}[0-9][A-Za-z0-9]? ?[0-9][A-Za-z]{2}" aria-describedby="pl-postcode-msg pl-postcode-extra">
    <p class="mt-1.5 flex items-start gap-1.5 text-[.8rem] text-mv-muted group-has-[:user-invalid]:font-semibold group-has-[:user-invalid]:text-mv-error group-has-[:user-invalid]:before:mt-px group-has-[:user-invalid]:before:grid group-has-[:user-invalid]:before:size-4 group-has-[:user-invalid]:before:flex-none group-has-[:user-invalid]:before:place-items-center group-has-[:user-invalid]:before:rounded-full group-has-[:user-invalid]:before:bg-mv-error group-has-[:user-invalid]:before:text-[.7rem] group-has-[:user-invalid]:before:font-extrabold group-has-[:user-invalid]:before:text-white group-has-[:user-invalid]:before:content-['!']" id="pl-postcode-msg">Enter a UK postcode, like LS6 2QT.</p>
    <p class="mt-1.5 hidden rounded-lg bg-[#eef6f4] px-2.5 py-2 text-[.8rem] text-mv-muted group-has-[:focus]:block" id="pl-postcode-extra">We use it to offer you the nearest site first.</p>
  </div>
  <div class="group mb-4 min-w-0">
    <label class="mb-1.5 block text-[.84rem] font-semibold" for="pl-tel">Phone (optional)</label>
    <input class="block w-full rounded-[10px] border border-mv-line bg-white bg-[length:18px] bg-[right_12px_center] bg-no-repeat py-[11px] pl-[13px] transition-[border-color,box-shadow] duration-150 focus:border-(--mv-accent) focus:shadow-[0_0_0_3px_var(--mv-ring)] focus:outline-none user-valid:border-mv-ok user-invalid:border-mv-error user-invalid:focus:shadow-[0_0_0_3px_rgba(185,28,28,.2)] motion-reduce:transition-none pr-10 user-valid:mv-valid-tick" id="pl-tel" name="tel" type="tel" autocomplete="tel" minlength="10" aria-describedby="pl-tel-msg">
    <p class="mt-1.5 flex items-start gap-1.5 text-[.8rem] text-mv-muted group-has-[:user-invalid]:font-semibold group-has-[:user-invalid]:text-mv-error group-has-[:user-invalid]:before:mt-px group-has-[:user-invalid]:before:grid group-has-[:user-invalid]:before:size-4 group-has-[:user-invalid]:before:flex-none group-has-[:user-invalid]:before:place-items-center group-has-[:user-invalid]:before:rounded-full group-has-[:user-invalid]:before:bg-mv-error group-has-[:user-invalid]:before:text-[.7rem] group-has-[:user-invalid]:before:font-extrabold group-has-[:user-invalid]:before:text-white group-has-[:user-invalid]:before:content-['!']" id="pl-tel-msg">At least 10 digits, for when a plot comes up.</p>
  </div>
  <button class="block w-full cursor-pointer rounded-[10px] bg-(--mv-accent) px-4 py-3 font-[650] text-white transition-[background-color,border-color,color] duration-200 hover:bg-(--mv-accent-dark) focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-(--mv-accent) group-has-[:invalid]/form:bg-mv-idle group-has-[:invalid]/form:text-mv-idle-ink group-has-[:invalid]/form:hover:bg-mv-idle motion-reduce:transition-none mt-[18px]" type="submit">Join the waiting list</button>
</form>
</div>
<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#4d5f5a] uppercase">Progress and reveal with :has()</span>
<form class="group/form rounded-[20px] bg-white px-[30px] pt-8 pb-7 text-[.92rem] leading-[1.45] text-mv-ink shadow-[0_1px_2px_rgba(16,32,28,.06),0_16px_36px_-16px_rgba(16,32,28,.25)] max-[420px]:px-5 max-[420px]:pt-[26px] max-[420px]:pb-[22px] [--mv-accent:#1d4ed8] [--mv-accent-dark:#1e40af] [--mv-ring:rgba(29,78,216,.22)]" action="#" method="post" aria-labelledby="rw-title">
  <div class="mb-5 flex items-center gap-2.5 font-bold"><span class="grid size-8 place-items-center rounded-[9px] bg-(--mv-accent) text-white"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 17c3 2 6 2 9 0s6-2 9 0M5 13L19 5M15 5h4v4"/></svg></span>Tidewater Rowing Club</div>
  <h2 class="text-[1.4rem] leading-[1.2] font-[750] tracking-[-.02em]" id="rw-title">Learn to Row, spring course</h2>
  <p class="mt-1.5 mb-5 text-mv-muted">Six Saturday mornings on the water from 7 March 2026. Every field is required unless it says optional.</p>
  <div class="group mb-4 min-w-0">
    <label class="mb-1.5 block text-[.84rem] font-semibold" for="rw-name">Full name</label>
    <input class="block w-full rounded-[10px] border border-mv-line bg-white bg-[length:18px] bg-[right_12px_center] bg-no-repeat py-[11px] pl-[13px] transition-[border-color,box-shadow] duration-150 focus:border-(--mv-accent) focus:shadow-[0_0_0_3px_var(--mv-ring)] focus:outline-none user-valid:border-mv-ok user-invalid:border-mv-error user-invalid:focus:shadow-[0_0_0_3px_rgba(185,28,28,.2)] motion-reduce:transition-none pr-10 user-valid:mv-valid-tick" id="rw-name" name="name" type="text" autocomplete="name" required minlength="2" aria-describedby="rw-name-msg">
    <p class="mt-1.5 flex items-start gap-1.5 text-[.8rem] text-mv-muted group-has-[:user-invalid]:font-semibold group-has-[:user-invalid]:text-mv-error group-has-[:user-invalid]:before:mt-px group-has-[:user-invalid]:before:grid group-has-[:user-invalid]:before:size-4 group-has-[:user-invalid]:before:flex-none group-has-[:user-invalid]:before:place-items-center group-has-[:user-invalid]:before:rounded-full group-has-[:user-invalid]:before:bg-mv-error group-has-[:user-invalid]:before:text-[.7rem] group-has-[:user-invalid]:before:font-extrabold group-has-[:user-invalid]:before:text-white group-has-[:user-invalid]:before:content-['!']" id="rw-name-msg">Enter your full name.</p>
  </div>
  <div class="group mb-4 min-w-0">
    <label class="mb-1.5 block text-[.84rem] font-semibold" for="rw-email">Email</label>
    <input class="block w-full rounded-[10px] border border-mv-line bg-white bg-[length:18px] bg-[right_12px_center] bg-no-repeat py-[11px] pl-[13px] transition-[border-color,box-shadow] duration-150 focus:border-(--mv-accent) focus:shadow-[0_0_0_3px_var(--mv-ring)] focus:outline-none user-valid:border-mv-ok user-invalid:border-mv-error user-invalid:focus:shadow-[0_0_0_3px_rgba(185,28,28,.2)] motion-reduce:transition-none pr-10 user-valid:mv-valid-tick" id="rw-email" name="email" type="email" autocomplete="email" required aria-describedby="rw-email-msg">
    <p class="mt-1.5 flex items-start gap-1.5 text-[.8rem] text-mv-muted group-has-[:user-invalid]:font-semibold group-has-[:user-invalid]:text-mv-error group-has-[:user-invalid]:before:mt-px group-has-[:user-invalid]:before:grid group-has-[:user-invalid]:before:size-4 group-has-[:user-invalid]:before:flex-none group-has-[:user-invalid]:before:place-items-center group-has-[:user-invalid]:before:rounded-full group-has-[:user-invalid]:before:bg-mv-error group-has-[:user-invalid]:before:text-[.7rem] group-has-[:user-invalid]:before:font-extrabold group-has-[:user-invalid]:before:text-white group-has-[:user-invalid]:before:content-['!']" id="rw-email-msg">Enter an email address, like [email protected].</p>
  </div>
  <div class="group mb-4 min-w-0">
    <label class="mb-1.5 block text-[.84rem] font-semibold" for="rw-dob">Date of birth</label>
    <input class="block w-full rounded-[10px] border border-mv-line bg-white bg-[length:18px] bg-[right_12px_center] bg-no-repeat py-[11px] pl-[13px] transition-[border-color,box-shadow] duration-150 focus:border-(--mv-accent) focus:shadow-[0_0_0_3px_var(--mv-ring)] focus:outline-none user-valid:border-mv-ok user-invalid:border-mv-error user-invalid:focus:shadow-[0_0_0_3px_rgba(185,28,28,.2)] motion-reduce:transition-none pr-[13px]" id="rw-dob" name="bday" type="date" autocomplete="bday" required min="1930-01-01" max="2010-03-07" aria-describedby="rw-dob-msg">
    <p class="mt-1.5 flex items-start gap-1.5 text-[.8rem] text-mv-muted group-has-[:user-invalid]:font-semibold group-has-[:user-invalid]:text-mv-error group-has-[:user-invalid]:before:mt-px group-has-[:user-invalid]:before:grid group-has-[:user-invalid]:before:size-4 group-has-[:user-invalid]:before:flex-none group-has-[:user-invalid]:before:place-items-center group-has-[:user-invalid]:before:rounded-full group-has-[:user-invalid]:before:bg-mv-error group-has-[:user-invalid]:before:text-[.7rem] group-has-[:user-invalid]:before:font-extrabold group-has-[:user-invalid]:before:text-white group-has-[:user-invalid]:before:content-['!']" id="rw-dob-msg">You need to be 16 or over on 7 March 2026.</p>
  </div>
  <div class="group mb-4 min-w-0">
    <label class="flex cursor-pointer items-start gap-2.5 text-[.86rem]"><input class="mt-px size-[18px] flex-none cursor-pointer appearance-none rounded-[5px] border-[1.5px] border-mv-line bg-white bg-[length:12px_12px] bg-center bg-no-repeat transition-[background-color,border-color,color] duration-150 checked:border-(--mv-accent) checked:bg-(--mv-accent) checked:mv-tick focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--mv-accent) user-invalid:border-mv-error motion-reduce:transition-none" id="rw-swim" name="swim" type="checkbox" required aria-describedby="rw-swim-msg"><span>I can swim 50 metres in light clothing</span></label>
    <p class="mt-1.5 flex items-start gap-1.5 text-[.8rem] text-mv-muted group-has-[:user-invalid]:font-semibold group-has-[:user-invalid]:text-mv-error group-has-[:user-invalid]:before:mt-px group-has-[:user-invalid]:before:grid group-has-[:user-invalid]:before:size-4 group-has-[:user-invalid]:before:flex-none group-has-[:user-invalid]:before:place-items-center group-has-[:user-invalid]:before:rounded-full group-has-[:user-invalid]:before:bg-mv-error group-has-[:user-invalid]:before:text-[.7rem] group-has-[:user-invalid]:before:font-extrabold group-has-[:user-invalid]:before:text-white group-has-[:user-invalid]:before:content-['!'] ml-7" id="rw-swim-msg">Everyone on the water has to be able to.</p>
  </div>
  <div class="mb-4 min-w-0">
    <label class="flex cursor-pointer items-start gap-2.5 text-[.86rem]"><input class="mt-px size-[18px] flex-none cursor-pointer appearance-none rounded-[5px] border-[1.5px] border-mv-line bg-white bg-[length:12px_12px] bg-center bg-no-repeat transition-[background-color,border-color,color] duration-150 checked:border-(--mv-accent) checked:bg-(--mv-accent) checked:mv-tick focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--mv-accent) user-invalid:border-mv-error motion-reduce:transition-none" name="rowed-before" type="checkbox"><span>I have rowed before (optional)</span></label>
    <div class="mt-3 hidden group-has-[[name=rowed-before]:checked]/form:block">
      <label class="mb-1.5 block text-[.84rem] font-semibold" for="rw-exp">Where and for how long? (optional)</label>
      <textarea class="block w-full rounded-[10px] border border-mv-line bg-white bg-[length:18px] bg-[right_12px_center] bg-no-repeat py-[11px] pl-[13px] transition-[border-color,box-shadow] duration-150 focus:border-(--mv-accent) focus:shadow-[0_0_0_3px_var(--mv-ring)] focus:outline-none user-valid:border-mv-ok user-invalid:border-mv-error user-invalid:focus:shadow-[0_0_0_3px_rgba(185,28,28,.2)] motion-reduce:transition-none min-h-20 resize-y pr-[13px]" id="rw-exp" name="experience" rows="2"></textarea>
    </div>
  </div>
  <div class="mt-5 mb-2 grid grid-cols-4 gap-[5px]" aria-hidden="true"><i class="h-1.5 rounded-[3px] bg-mv-idle transition-[background-color,border-color,color] duration-200 motion-reduce:transition-none group-has-[#rw-name:valid]/form:bg-mv-ok"></i><i class="h-1.5 rounded-[3px] bg-mv-idle transition-[background-color,border-color,color] duration-200 motion-reduce:transition-none group-has-[#rw-email:valid]/form:bg-mv-ok"></i><i class="h-1.5 rounded-[3px] bg-mv-idle transition-[background-color,border-color,color] duration-200 motion-reduce:transition-none group-has-[#rw-dob:valid]/form:bg-mv-ok"></i><i class="h-1.5 rounded-[3px] bg-mv-idle transition-[background-color,border-color,color] duration-200 motion-reduce:transition-none group-has-[#rw-swim:checked]/form:bg-mv-ok"></i></div>
  <p class="mb-3 text-[.8rem] text-mv-muted" aria-hidden="true"><span class="hidden group-has-[:invalid]/form:inline">Fill in the four required fields to book.</span><span class="font-semibold text-mv-ok group-has-[:invalid]/form:hidden">All set. You can book your place.</span></p>
  <button class="block w-full cursor-pointer rounded-[10px] bg-(--mv-accent) px-4 py-3 font-[650] text-white transition-[background-color,border-color,color] duration-200 hover:bg-(--mv-accent-dark) focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-(--mv-accent) group-has-[:invalid]/form:bg-mv-idle group-has-[:invalid]/form:text-mv-idle-ink group-has-[:invalid]/form:hover:bg-mv-idle motion-reduce:transition-none" type="submit">Book my place, £85</button>
</form>
</div>
</div>
CSS
@theme {
  --color-mv-ink: #10201c;
  --color-mv-muted: #4b5c58;
  --color-mv-line: #788884;
  --color-mv-ok: #15803d;
  --color-mv-error: #b91c1c;
  --color-mv-idle: #d5dedb;
  --color-mv-idle-ink: #28403a;
}
/* green tick inside a valid field, white tick in a checked box */
@utility mv-valid-tick {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M5 12.5l4.5 4.5L19 7.5' fill='none' stroke='%2315803d' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
}
@utility mv-tick {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.5l3 3 6-7' fill='none' stroke='%23fff' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
}

About this set

Two sign-up forms that do live validation, hints, progress and a ready state with CSS alone. Errors only after interaction is an allotment waiting list form: name, email, a UK postcode with a pattern, and an optional phone number. A fresh form shows no red at all, because :user-invalid and :user-valid only match after someone has changed a field or tried to submit. Valid fields then get a green border and tick, invalid ones a red border, and each field’s message, which states the requirement from the start, turns red with an exclamation icon. The postcode explains why it is asked for in a note that opens only while the field has focus. Progress and reveal with :has() books a learn-to-row course: name, email, a date of birth limited by max to people 16 or over, a required swimming checkbox and an optional I have rowed before checkbox that reveals a textarea. Four segments light up as each required field becomes valid, a line switches to All set, and the button turns from neutral grey to blue once nothing is invalid. The button is never disabled, so submitting still triggers the browser’s own messages.

What’s included

  • :user-invalid and :user-valid, so errors appear only after interaction
  • Requirement messages linked with aria-describedby that double as error text
  • Focus-only hint, revealed optional field and progress segments driven by :has()
  • Submit button changes look when the form is valid but is never disabled
  • No JavaScript in any of the three versions

Accessibility

Every input has a visible label and autocomplete value, and its message is linked with aria-describedby from the start, so screen reader users hear the requirement before they make a mistake. Errors combine a red border, an icon and bold text, so colour is never the only signal. The progress segments and the All set line are aria-hidden extras that repeat what the fields already say. The submit button stays enabled and keyboard reachable, focus rings are visible, and transitions switch off under prefers-reduced-motion.

Customise it

Change --mv-accent, --mv-ok and --mv-error on .mv (.mv--blue is the second palette) and --mv-idle-bg for the neutral button. The progress rules name each required field by id, so update the #rw-… selectors if you rename fields. In Tailwind the same logic is written with user-valid:, user-invalid:, group-has-[…] and group-has-[…]/form: variants.