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