Open demo

CSS code

HTML
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><symbol id="rf-star" viewBox="0 0 20 20"><path d="M9.05 2.93c.3-.92 1.6-.92 1.9 0l1.07 3.29a1 1 0 0 0 .95.69h3.46c.97 0 1.37 1.24.59 1.81l-2.8 2.03a1 1 0 0 0-.36 1.12l1.07 3.3c.3.91-.76 1.68-1.54 1.11l-2.8-2.03a1 1 0 0 0-1.18 0l-2.8 2.03c-.78.57-1.84-.2-1.54-1.11l1.07-3.3a1 1 0 0 0-.36-1.12L2.98 8.72c-.78-.57-.38-1.81.59-1.81h3.46a1 1 0 0 0 .95-.69z"/></symbol></svg>

<div><span class="lbl">Product review form</span>
<form class="rf" data-review data-done="Thank you. Your review has been sent and will appear after a quick check." action="#" method="post" aria-labelledby="rf-p1">
  <div class="rf-product"><span class="rf-thumb" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M10 2v2M14 2v2M6 2v2"/><path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1"/></svg></span>
    <div><h2 id="rf-p1">Review the Larkspur Pour-Over Kettle</h2><p>Matte black · 1 litre · bought 3 weeks ago</p></div></div>

  <fieldset class="rf-field" data-check="Choose a star rating from 1 to 5." aria-describedby="rf-rating-err">
    <legend>Overall rating<span class="rf-req">Required</span></legend>
    <div class="rf-stars">
      <input type="radio" name="rating" id="rf-rating-0" value="" checked><label class="rf-clear" for="rf-rating-0">Clear<span class="sr-only"> rating</span></label>
      <input type="radio" name="rating" id="rf-rating-1" value="1"><label class="rf-star" for="rf-rating-1"><svg aria-hidden="true"><use href="#rf-star"/></svg><span class="sr-only">1 star</span></label>
      <input type="radio" name="rating" id="rf-rating-2" value="2"><label class="rf-star" for="rf-rating-2"><svg aria-hidden="true"><use href="#rf-star"/></svg><span class="sr-only">2 stars</span></label>
      <input type="radio" name="rating" id="rf-rating-3" value="3"><label class="rf-star" for="rf-rating-3"><svg aria-hidden="true"><use href="#rf-star"/></svg><span class="sr-only">3 stars</span></label>
      <input type="radio" name="rating" id="rf-rating-4" value="4"><label class="rf-star" for="rf-rating-4"><svg aria-hidden="true"><use href="#rf-star"/></svg><span class="sr-only">4 stars</span></label>
      <input type="radio" name="rating" id="rf-rating-5" value="5"><label class="rf-star" for="rf-rating-5"><svg aria-hidden="true"><use href="#rf-star"/></svg><span class="sr-only">5 stars</span></label>
      <span class="rf-word" aria-hidden="true"><span data-v="">Not rated</span><span data-v="1"><b>Poor</b></span><span data-v="2"><b>Fair</b></span><span data-v="3"><b>Good</b></span><span data-v="4"><b>Very good</b></span><span data-v="5"><b>Excellent</b></span></span>
    </div>
    <p class="rf-err" id="rf-rating-err"></p>
  </fieldset>

  <div class="rf-field">
    <label class="rf-label" for="rf-title">Review title<span class="rf-req">Required</span></label>
    <input class="rf-input" id="rf-title" name="title" type="text" maxlength="80" required placeholder="Pours like a dream" aria-describedby="rf-title-err" data-check="Give your review a title.">
    <p class="rf-err" id="rf-title-err"></p>
  </div>

  <div class="rf-field">
    <label class="rf-label" for="rf-body">Your review<span class="rf-req">Required</span></label>
    <p class="rf-hint" id="rf-body-hint">What did you like or dislike? At least 30 characters.</p>
    <textarea class="rf-input" id="rf-body" name="review" minlength="30" maxlength="1000" required aria-describedby="rf-body-hint rf-body-err" data-check="Write at least 30 characters about the product."></textarea>
    <p class="rf-count" aria-hidden="true"><span data-count>0</span> / 1000</p>
    <p class="rf-err" id="rf-body-err"></p>
  </div>

  <div class="rf-two">
    <fieldset class="rf-field">
      <legend>Would you recommend it?</legend>
      <div class="rf-pills">
        <input type="radio" name="recommend" id="rf-rec-yes" value="yes"><label for="rf-rec-yes">Yes</label>
        <input type="radio" name="recommend" id="rf-rec-no" value="no"><label for="rf-rec-no">No</label>
      </div>
    </fieldset>
    <div class="rf-field">
      <label class="rf-label" for="rf-name">Display name<span class="rf-req">Required</span></label>
      <input class="rf-input" id="rf-name" name="name" type="text" autocomplete="nickname" required aria-describedby="rf-name-err" data-check="Enter the name to show with your review.">
      <p class="rf-err" id="rf-name-err"></p>
    </div>
  </div>

  <div class="rf-actions"><button class="rf-btn" type="submit">Post review</button>
    <p class="rf-note">Reviews appear after a quick check, usually within a day.</p></div>
  <p class="rf-status" role="status"></p>
</form></div>

<div><span class="lbl">Quick rating after delivery</span>
<form class="rf rf-compact" data-review data-done="Thank you. Your delivery rating has been sent." action="#" method="post" aria-labelledby="rf-p2">
  <div class="rf-product"><div><h2 id="rf-p2">How did order #48213 go?</h2><p>Delivered on Tuesday 14 October</p></div></div>

  <fieldset class="rf-field" data-check="Choose a star rating for the delivery." aria-describedby="rf-del-err">
    <legend>Delivery rating<span class="rf-req">Required</span></legend>
    <div class="rf-stars">
      <input type="radio" name="delivery" id="rf-del-0" value="" checked><label class="rf-clear" for="rf-del-0">Clear<span class="sr-only"> rating</span></label>
      <input type="radio" name="delivery" id="rf-del-1" value="1"><label class="rf-star" for="rf-del-1"><svg aria-hidden="true"><use href="#rf-star"/></svg><span class="sr-only">1 star</span></label>
      <input type="radio" name="delivery" id="rf-del-2" value="2"><label class="rf-star" for="rf-del-2"><svg aria-hidden="true"><use href="#rf-star"/></svg><span class="sr-only">2 stars</span></label>
      <input type="radio" name="delivery" id="rf-del-3" value="3"><label class="rf-star" for="rf-del-3"><svg aria-hidden="true"><use href="#rf-star"/></svg><span class="sr-only">3 stars</span></label>
      <input type="radio" name="delivery" id="rf-del-4" value="4"><label class="rf-star" for="rf-del-4"><svg aria-hidden="true"><use href="#rf-star"/></svg><span class="sr-only">4 stars</span></label>
      <input type="radio" name="delivery" id="rf-del-5" value="5"><label class="rf-star" for="rf-del-5"><svg aria-hidden="true"><use href="#rf-star"/></svg><span class="sr-only">5 stars</span></label>
    </div>
    <p class="rf-err" id="rf-del-err"></p>
  </fieldset>

  <fieldset class="rf-field">
    <legend>What went well?</legend>
    <div class="rf-chips">
      <input type="checkbox" name="went_well" id="rf-w1" value="on-time"><label for="rf-w1"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8.5l3 3 7-7"/></svg>Arrived on time</label>
      <input type="checkbox" name="went_well" id="rf-w2" value="packaging"><label for="rf-w2"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8.5l3 3 7-7"/></svg>Well packed</label>
      <input type="checkbox" name="went_well" id="rf-w3" value="as-pictured"><label for="rf-w3"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8.5l3 3 7-7"/></svg>Matches the photos</label>
      <input type="checkbox" name="went_well" id="rf-w4" value="courier"><label for="rf-w4"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8.5l3 3 7-7"/></svg>Friendly courier</label>
    </div>
  </fieldset>

  <div class="rf-field">
    <label class="rf-label" for="rf-note">Anything else? <span class="rf-req">Optional</span></label>
    <textarea class="rf-input rf-short" id="rf-note" name="note" maxlength="300" rows="3"></textarea>
  </div>

  <div class="rf-actions"><button class="rf-btn" type="submit">Send rating</button></div>
  <p class="rf-status" role="status"></p>
</form></div>
CSS
/* Review forms. The stars are a native radio group in a fieldset (1→5 order; "Clear" starts checked and every star
   after the checked radio is grey). A short script turns off the browser bubbles, checks each field on submit, writes
   the error under it, links it with aria-describedby, marks it aria-invalid and moves focus to the first problem.
   Without the script the browser's own required checks still run. */
.rf{--rf-on:#fbbf24;--rf-on-edge:#b45309;--rf-off:#f3f1ed;--rf-edge:#878d98;--rf-ink:#1f2328;--rf-muted:#5b616e;--rf-line:#e8e3da;
  --rf-focus:#1d4ed8;--rf-bad:#b91c1c;--rf-good:#166534;--rf-size:34px;
  padding:28px;background:#fff;border:1px solid var(--rf-line);border-radius:22px;color:var(--rf-ink)}
.rf .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.rf-product{display:flex;align-items:center;gap:14px;padding-bottom:22px;margin-bottom:22px;border-bottom:1px solid var(--rf-line)}
.rf-thumb{flex:none;display:grid;place-items:center;width:56px;height:56px;border-radius:14px;background:linear-gradient(140deg,#1f2328,#4b5361);color:#fbbf24}
.rf-thumb svg{width:28px;height:28px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}
.rf-product h2{margin:0;font-size:1.1rem;font-weight:800}
.rf-product p{margin:3px 0 0;font-size:.88rem;color:var(--rf-muted)}
.rf .rf-field{margin-top:22px}
.rf fieldset{border:0;margin:0;padding:0;min-width:0}
.rf legend,.rf-label{display:block;padding:0;font-size:.95rem;font-weight:700}
.rf-req{margin-left:6px;font-size:.8rem;font-weight:600;color:var(--rf-muted)}
.rf-hint{margin-top:4px;font-size:.85rem;color:var(--rf-muted)}
.rf-input{display:block;width:100%;margin-top:8px;padding:12px 14px;border:1.5px solid var(--rf-edge);border-radius:12px;background:#fff;
  font:inherit;font-size:1rem;color:var(--rf-ink);transition:border-color .15s}
textarea.rf-input{min-height:140px;resize:vertical;line-height:1.5}
textarea.rf-short{min-height:96px}
.rf-input:hover{border-color:var(--rf-ink)}
.rf-input:focus-visible{outline:3px solid var(--rf-focus);outline-offset:1px;border-color:var(--rf-ink)}
.rf-input[aria-invalid="true"]{border-color:var(--rf-bad);background:#fffafa}
.rf-count{margin-top:6px;font-size:.82rem;color:var(--rf-muted);text-align:right;font-variant-numeric:tabular-nums}
.rf-err{display:flex;gap:7px;align-items:flex-start;margin-top:8px;font-size:.88rem;font-weight:600;color:var(--rf-bad)}
.rf-err:empty{display:none}
.rf-err::before{content:"!";flex:none;display:grid;place-items:center;width:18px;height:18px;margin-top:1px;border-radius:50%;background:var(--rf-bad);color:#fff;font-size:.75rem;font-weight:800}
.rf-two{display:grid;gap:0 18px;grid-template-columns:minmax(0,1fr)}
@media (min-width:600px){.rf-two{grid-template-columns:1fr 1fr}}

/* the star row */
.rf-stars{position:relative;display:flex;flex-wrap:wrap;align-items:center;margin-top:8px;pointer-events:none}
.rf-stars label{pointer-events:auto;cursor:pointer}
.rf-stars input,.rf-pills input,.rf-chips input{position:absolute;top:0;left:0;width:1px;height:1px;margin:0;opacity:0;pointer-events:none}
.rf-star{position:relative;display:block;padding:4px;border-radius:10px}
.rf-star svg{display:block;width:var(--rf-size);height:var(--rf-size);fill:var(--rf-on);stroke:var(--rf-on-edge);stroke-width:1.1;stroke-linejoin:round;transition:fill .15s,stroke .15s,transform .15s}
.rf-stars input:checked+label~.rf-star svg{fill:var(--rf-off);stroke:var(--rf-edge)}
@media (hover:hover){
  .rf .rf-stars:hover .rf-star svg{fill:var(--rf-on);stroke:var(--rf-on-edge)}
  .rf .rf-stars:hover label:hover~.rf-star svg{fill:var(--rf-off);stroke:var(--rf-edge)}
  .rf .rf-star:hover svg{transform:scale(1.12)}
}
.rf-star::after{content:"";position:absolute;left:50%;bottom:-4px;width:14px;height:3px;margin-left:-7px;border-radius:2px}
.rf-stars input:checked+.rf-star::after{background:var(--rf-ink)}
.rf-clear{order:1;margin-left:10px;padding:6px 12px;border:1px solid var(--rf-line);border-radius:999px;font-size:.82rem;font-weight:600;color:var(--rf-muted);background:#fff}
.rf-clear:hover{border-color:var(--rf-edge);color:var(--rf-ink)}
.rf-stars input:checked+.rf-clear{border-style:dashed}
.rf-word{order:2;margin-left:14px;font-size:.92rem;color:var(--rf-muted)}
.rf-word span{display:none}
.rf-stars input[value=""]:checked~.rf-word [data-v=""],.rf-stars input[value="1"]:checked~.rf-word [data-v="1"],.rf-stars input[value="2"]:checked~.rf-word [data-v="2"],
.rf-stars input[value="3"]:checked~.rf-word [data-v="3"],.rf-stars input[value="4"]:checked~.rf-word [data-v="4"],.rf-stars input[value="5"]:checked~.rf-word [data-v="5"]{display:inline}
.rf-word b{color:var(--rf-ink)}
.rf fieldset[aria-invalid="true"] .rf-stars .rf-star svg{stroke:var(--rf-bad)}
.rf input:focus-visible+label{outline:3px solid var(--rf-focus);outline-offset:1px}

/* yes / no pills and tag chips */
.rf-pills,.rf-chips{position:relative;display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}
.rf-pills label,.rf-chips label{display:inline-flex;align-items:center;gap:7px;padding:9px 16px;border:1.5px solid var(--rf-edge);border-radius:999px;cursor:pointer;
  font-size:.92rem;font-weight:600;background:#fff;transition:background .15s,color .15s,border-color .15s}
.rf-pills label:hover,.rf-chips label:hover{border-color:var(--rf-ink)}
.rf-pills input:checked+label,.rf-chips input:checked+label{background:var(--rf-ink);border-color:var(--rf-ink);color:#fff}
.rf-chips label svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;stroke-linejoin:round;display:none}
.rf-chips input:checked+label svg{display:block}
.rf-chips label{padding-left:14px}

.rf-actions{display:flex;flex-wrap:wrap;align-items:center;gap:12px 18px;margin-top:26px}
.rf-btn{padding:13px 24px;border:0;border-radius:12px;background:var(--rf-ink);color:#fff;font:inherit;font-weight:700;font-size:.98rem;cursor:pointer;transition:background .15s}
.rf-btn:hover{background:#3a4049}
.rf-btn:focus-visible{outline:3px solid var(--rf-focus);outline-offset:3px}
.rf-note{flex:1 1 220px;font-size:.84rem;color:var(--rf-muted)}
.rf-status{margin-top:16px;font-size:.92rem;font-weight:600;color:var(--rf-good)}
.rf-status:empty{display:none}
.rf-compact{max-width:520px}
.rf-compact .rf-product{padding-bottom:18px;margin-bottom:4px}
@media (max-width:480px){.rf{padding:22px 18px}.rf{--rf-size:30px}}
@media (prefers-reduced-motion:reduce){.rf-star svg,.rf-input,.rf-pills label,.rf-chips label,.rf-btn{transition:none}.rf .rf-star:hover svg{transform:none}}
JavaScript
// Review form validation: one function per form, no library. Fields to check carry data-check="error message".
document.querySelectorAll('form[data-review]').forEach(function (form) {
  form.noValidate = true; // we show our own messages; without this script the browser's checks run instead
  var text = form.querySelector('textarea[maxlength][minlength]'), count = form.querySelector('[data-count]');
  if (text && count) text.addEventListener('input', function () { count.textContent = text.value.length; });

  function problem(field) {
    if (field.matches('fieldset')) { var c = field.querySelector('input:checked'); return !c || c.value === ''; }
    var v = field.value.trim();
    return (field.required && !v) || (field.minLength > 0 && v.length < field.minLength);
  }
  function check(field) {
    var bad = problem(field), err = document.getElementById(field.getAttribute('aria-describedby').split(' ').pop());
    err.textContent = bad ? field.dataset.check : '';
    if (bad) field.setAttribute('aria-invalid', 'true'); else field.removeAttribute('aria-invalid');
    return bad;
  }
  // once a field has been flagged, re-check it as the user fixes it
  form.addEventListener('input', function (e) { var f = e.target.closest('[data-check]'); if (f && f.hasAttribute('aria-invalid')) check(f); });
  form.addEventListener('change', function (e) { var f = e.target.closest('[data-check]'); if (f && f.hasAttribute('aria-invalid')) check(f); });

  form.addEventListener('submit', function (e) {
    e.preventDefault();
    var status = form.querySelector('[role="status"]'), first = null;
    form.querySelectorAll('[data-check]').forEach(function (f) { if (check(f) && !first) first = f; });
    if (first) { status.textContent = ''; (first.matches('fieldset') ? first.querySelector('input:checked') : first).focus(); return; }
    status.textContent = form.dataset.done;
    form.reset(); if (count) count.textContent = '0';
  });
});

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><symbol id="rf-star" viewBox="0 0 20 20"><path d="M9.05 2.93c.3-.92 1.6-.92 1.9 0l1.07 3.29a1 1 0 0 0 .95.69h3.46c.97 0 1.37 1.24.59 1.81l-2.8 2.03a1 1 0 0 0-.36 1.12l1.07 3.3c.3.91-.76 1.68-1.54 1.11l-2.8-2.03a1 1 0 0 0-1.18 0l-2.8 2.03c-.78.57-1.84-.2-1.54-1.11l1.07-3.3a1 1 0 0 0-.36-1.12L2.98 8.72c-.78-.57-.38-1.81.59-1.81h3.46a1 1 0 0 0 .95-.69z"/></symbol></svg>

<div><span class="lbl">Product review form</span>
<form class="card rf" data-review data-done="Thank you. Your review has been sent and will appear after a quick check." action="#" method="post" aria-labelledby="rf-p1">
  <div class="rf-product"><span class="rf-thumb" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M10 2v2M14 2v2M6 2v2"/><path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1"/></svg></span>
    <div><h2 id="rf-p1">Review the Larkspur Pour-Over Kettle</h2><p>Matte black · 1 litre · bought 3 weeks ago</p></div></div>

  <fieldset class="rf-field" data-check="Choose a star rating from 1 to 5." aria-describedby="rf-rating-err">
    <legend>Overall rating<span class="rf-req">Required</span></legend>
    <div class="rf-stars">
      <input class="btn-check" type="radio" name="rating" id="rf-rating-0" value="" checked><label class="rf-clear btn rounded-pill" for="rf-rating-0">Clear<span class="visually-hidden"> rating</span></label>
      <input class="btn-check" type="radio" name="rating" id="rf-rating-1" value="1"><label class="rf-star" for="rf-rating-1"><svg aria-hidden="true"><use href="#rf-star"/></svg><span class="visually-hidden">1 star</span></label>
      <input class="btn-check" type="radio" name="rating" id="rf-rating-2" value="2"><label class="rf-star" for="rf-rating-2"><svg aria-hidden="true"><use href="#rf-star"/></svg><span class="visually-hidden">2 stars</span></label>
      <input class="btn-check" type="radio" name="rating" id="rf-rating-3" value="3"><label class="rf-star" for="rf-rating-3"><svg aria-hidden="true"><use href="#rf-star"/></svg><span class="visually-hidden">3 stars</span></label>
      <input class="btn-check" type="radio" name="rating" id="rf-rating-4" value="4"><label class="rf-star" for="rf-rating-4"><svg aria-hidden="true"><use href="#rf-star"/></svg><span class="visually-hidden">4 stars</span></label>
      <input class="btn-check" type="radio" name="rating" id="rf-rating-5" value="5"><label class="rf-star" for="rf-rating-5"><svg aria-hidden="true"><use href="#rf-star"/></svg><span class="visually-hidden">5 stars</span></label>
      <span class="rf-word" aria-hidden="true"><span data-v="">Not rated</span><span data-v="1"><b>Poor</b></span><span data-v="2"><b>Fair</b></span><span data-v="3"><b>Good</b></span><span data-v="4"><b>Very good</b></span><span data-v="5"><b>Excellent</b></span></span>
    </div>
    <p class="rf-err" id="rf-rating-err"></p>
  </fieldset>

  <div class="rf-field">
    <label class="form-label" for="rf-title">Review title<span class="rf-req">Required</span></label>
    <input class="form-control" id="rf-title" name="title" type="text" maxlength="80" required placeholder="Pours like a dream" aria-describedby="rf-title-err" data-check="Give your review a title.">
    <p class="rf-err" id="rf-title-err"></p>
  </div>

  <div class="rf-field">
    <label class="form-label" for="rf-body">Your review<span class="rf-req">Required</span></label>
    <p class="form-text" id="rf-body-hint">What did you like or dislike? At least 30 characters.</p>
    <textarea class="form-control" id="rf-body" name="review" minlength="30" maxlength="1000" required aria-describedby="rf-body-hint rf-body-err" data-check="Write at least 30 characters about the product."></textarea>
    <p class="rf-count" aria-hidden="true"><span data-count>0</span> / 1000</p>
    <p class="rf-err" id="rf-body-err"></p>
  </div>

  <div class="rf-two row">
    <div class="col-sm-6"><fieldset class="rf-field">
      <legend>Would you recommend it?</legend>
      <div class="rf-pills">
        <input class="btn-check" type="radio" name="recommend" id="rf-rec-yes" value="yes"><label class="btn" for="rf-rec-yes">Yes</label>
        <input class="btn-check" type="radio" name="recommend" id="rf-rec-no" value="no"><label class="btn" for="rf-rec-no">No</label>
      </div>
    </fieldset></div>
    <div class="rf-field col-sm-6">
      <label class="form-label" for="rf-name">Display name<span class="rf-req">Required</span></label>
      <input class="form-control" id="rf-name" name="name" type="text" autocomplete="nickname" required aria-describedby="rf-name-err" data-check="Enter the name to show with your review.">
      <p class="rf-err" id="rf-name-err"></p>
    </div>
  </div>

  <div class="rf-actions"><button class="btn btn-dark" type="submit">Post review</button>
    <p class="rf-note">Reviews appear after a quick check, usually within a day.</p></div>
  <p class="rf-status" role="status"></p>
</form></div>

<div><span class="lbl">Quick rating after delivery</span>
<form class="card rf rf-compact" data-review data-done="Thank you. Your delivery rating has been sent." action="#" method="post" aria-labelledby="rf-p2">
  <div class="rf-product"><div><h2 id="rf-p2">How did order #48213 go?</h2><p>Delivered on Tuesday 14 October</p></div></div>

  <fieldset class="rf-field" data-check="Choose a star rating for the delivery." aria-describedby="rf-del-err">
    <legend>Delivery rating<span class="rf-req">Required</span></legend>
    <div class="rf-stars">
      <input class="btn-check" type="radio" name="delivery" id="rf-del-0" value="" checked><label class="rf-clear btn rounded-pill" for="rf-del-0">Clear<span class="visually-hidden"> rating</span></label>
      <input class="btn-check" type="radio" name="delivery" id="rf-del-1" value="1"><label class="rf-star" for="rf-del-1"><svg aria-hidden="true"><use href="#rf-star"/></svg><span class="visually-hidden">1 star</span></label>
      <input class="btn-check" type="radio" name="delivery" id="rf-del-2" value="2"><label class="rf-star" for="rf-del-2"><svg aria-hidden="true"><use href="#rf-star"/></svg><span class="visually-hidden">2 stars</span></label>
      <input class="btn-check" type="radio" name="delivery" id="rf-del-3" value="3"><label class="rf-star" for="rf-del-3"><svg aria-hidden="true"><use href="#rf-star"/></svg><span class="visually-hidden">3 stars</span></label>
      <input class="btn-check" type="radio" name="delivery" id="rf-del-4" value="4"><label class="rf-star" for="rf-del-4"><svg aria-hidden="true"><use href="#rf-star"/></svg><span class="visually-hidden">4 stars</span></label>
      <input class="btn-check" type="radio" name="delivery" id="rf-del-5" value="5"><label class="rf-star" for="rf-del-5"><svg aria-hidden="true"><use href="#rf-star"/></svg><span class="visually-hidden">5 stars</span></label>
    </div>
    <p class="rf-err" id="rf-del-err"></p>
  </fieldset>

  <fieldset class="rf-field">
    <legend>What went well?</legend>
    <div class="rf-chips">
      <input class="btn-check" type="checkbox" name="went_well" id="rf-w1" value="on-time"><label class="btn" for="rf-w1"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8.5l3 3 7-7"/></svg>Arrived on time</label>
      <input class="btn-check" type="checkbox" name="went_well" id="rf-w2" value="packaging"><label class="btn" for="rf-w2"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8.5l3 3 7-7"/></svg>Well packed</label>
      <input class="btn-check" type="checkbox" name="went_well" id="rf-w3" value="as-pictured"><label class="btn" for="rf-w3"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8.5l3 3 7-7"/></svg>Matches the photos</label>
      <input class="btn-check" type="checkbox" name="went_well" id="rf-w4" value="courier"><label class="btn" for="rf-w4"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8.5l3 3 7-7"/></svg>Friendly courier</label>
    </div>
  </fieldset>

  <div class="rf-field">
    <label class="form-label" for="rf-note">Anything else? <span class="rf-req">Optional</span></label>
    <textarea class="form-control rf-short" id="rf-note" name="note" maxlength="300" rows="3"></textarea>
  </div>

  <div class="rf-actions"><button class="btn btn-dark" type="submit">Send rating</button></div>
  <p class="rf-status" role="status"></p>
</form></div>
CSS
/* Review forms on Bootstrap: .card, .form-control, .form-label, .form-text, .btn-check with .btn for the choices, .is-invalid for errors.
   The stars are a native radio group in a fieldset (1→5 order; "Clear" starts checked and every star
   after the checked radio is grey). A short script turns off the browser bubbles, checks each field on submit, writes
   the error under it, links it with aria-describedby, marks it aria-invalid and moves focus to the first problem.
   Without the script the browser's own required checks still run. */
.rf{--rf-on:#fbbf24;--rf-on-edge:#b45309;--rf-off:#f3f1ed;--rf-edge:#878d98;--rf-ink:#1f2328;--rf-muted:#5b616e;--rf-line:#e8e3da;
  --rf-focus:#1d4ed8;--rf-bad:#b91c1c;--rf-good:#166534;--rf-size:34px;
  --bs-card-border-color:var(--rf-line);--bs-card-border-radius:22px;--bs-card-color:var(--rf-ink);--bs-form-invalid-border-color:var(--rf-bad);
  display:block;padding:28px;color:var(--rf-ink)}
.rf p{margin-bottom:0}
.rf-product{display:flex;align-items:center;gap:14px;padding-bottom:22px;margin-bottom:22px;border-bottom:1px solid var(--rf-line)}
.rf-thumb{flex:none;display:grid;place-items:center;width:56px;height:56px;border-radius:14px;background:linear-gradient(140deg,#1f2328,#4b5361);color:#fbbf24}
.rf-thumb svg{width:28px;height:28px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}
.rf-product h2{margin:0;font-size:1.1rem;font-weight:800}
.rf-product p{margin:3px 0 0;font-size:.88rem;color:var(--rf-muted)}
.rf .rf-field{margin-top:22px}
.rf fieldset{border:0;margin:0;padding:0;min-width:0}
.rf legend,.rf .form-label{float:none;display:block;margin:0;padding:0;font-size:.95rem;line-height:1.3;font-weight:700}
.rf-req{margin-left:6px;font-size:.8rem;font-weight:600;color:var(--rf-muted)}
.rf .form-text{margin-top:4px;font-size:.85rem;color:var(--rf-muted)}
.rf .form-control{margin-top:8px;padding:12px 14px;border:1.5px solid var(--rf-edge);border-radius:12px;font-size:1rem;line-height:normal;color:var(--rf-ink);transition:border-color .15s}
.rf textarea.form-control{min-height:140px;resize:vertical;line-height:1.5}
.rf textarea.rf-short{min-height:96px}
.rf .form-control:hover{border-color:var(--rf-ink)}
.rf .form-control:focus{box-shadow:none;border-color:var(--rf-ink)}
.rf .form-control:focus-visible{outline:3px solid var(--rf-focus);outline-offset:1px}
.rf .form-control.is-invalid{border-color:var(--rf-bad);background-color:#fffafa;background-image:none;padding-right:14px}
.rf-count{margin-top:6px;font-size:.82rem;color:var(--rf-muted);text-align:right;font-variant-numeric:tabular-nums}
.rf-err{display:flex;gap:7px;align-items:flex-start;margin-top:8px;font-size:.88rem;font-weight:600;color:var(--rf-bad)}
.rf-err:empty{display:none}
.rf-err::before{content:"!";flex:none;display:grid;place-items:center;width:18px;height:18px;margin-top:1px;border-radius:50%;background:var(--rf-bad);color:#fff;font-size:.75rem;font-weight:800}
.rf-two{--bs-gutter-x:18px;--bs-gutter-y:0}

/* the star row */
.rf-stars{position:relative;display:flex;flex-wrap:wrap;align-items:center;margin-top:8px;pointer-events:none}
.rf-stars label{pointer-events:auto;cursor:pointer}
.rf-stars input,.rf-pills input,.rf-chips input{position:absolute;top:0;left:0;width:1px;height:1px;margin:0;opacity:0;pointer-events:none}
.rf-star{position:relative;display:block;padding:4px;border-radius:10px}
.rf-star svg{display:block;width:var(--rf-size);height:var(--rf-size);fill:var(--rf-on);stroke:var(--rf-on-edge);stroke-width:1.1;stroke-linejoin:round;transition:fill .15s,stroke .15s,transform .15s}
.rf-stars input:checked+label~.rf-star svg{fill:var(--rf-off);stroke:var(--rf-edge)}
@media (hover:hover){
  .rf .rf-stars:hover .rf-star svg{fill:var(--rf-on);stroke:var(--rf-on-edge)}
  .rf .rf-stars:hover label:hover~.rf-star svg{fill:var(--rf-off);stroke:var(--rf-edge)}
  .rf .rf-star:hover svg{transform:scale(1.12)}
}
.rf-star::after{content:"";position:absolute;left:50%;bottom:-4px;width:14px;height:3px;margin-left:-7px;border-radius:2px}
.rf-stars input:checked+.rf-star::after{background:var(--rf-ink)}
.rf-clear{--bs-btn-padding-x:12px;--bs-btn-padding-y:6px;--bs-btn-font-size:.82rem;--bs-btn-font-weight:600;--bs-btn-line-height:normal;
  --bs-btn-color:var(--rf-muted);--bs-btn-bg:#fff;--bs-btn-border-color:var(--rf-line);--bs-btn-hover-color:var(--rf-ink);--bs-btn-hover-bg:#fff;--bs-btn-hover-border-color:var(--rf-edge);
  --bs-btn-active-color:var(--rf-muted);--bs-btn-active-bg:#fff;--bs-btn-active-border-color:var(--rf-line);order:1;margin-left:10px;transition:none}
.rf-stars .btn-check:checked+.rf-clear{border-style:dashed}
.rf-word{order:2;margin-left:14px;font-size:.92rem;color:var(--rf-muted)}
.rf-word span{display:none}
.rf-stars input[value=""]:checked~.rf-word [data-v=""],.rf-stars input[value="1"]:checked~.rf-word [data-v="1"],.rf-stars input[value="2"]:checked~.rf-word [data-v="2"],
.rf-stars input[value="3"]:checked~.rf-word [data-v="3"],.rf-stars input[value="4"]:checked~.rf-word [data-v="4"],.rf-stars input[value="5"]:checked~.rf-word [data-v="5"]{display:inline}
.rf-word b{color:var(--rf-ink)}
.rf fieldset[aria-invalid="true"] .rf-stars .rf-star svg{stroke:var(--rf-bad)}
.rf input:focus-visible+label,.rf .btn-check:focus-visible+.btn{outline:3px solid var(--rf-focus);outline-offset:1px;box-shadow:none}

/* yes / no pills and tag chips */
.rf-pills,.rf-chips{position:relative;display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}
.rf-pills .btn,.rf-chips .btn{--bs-btn-padding-x:16px;--bs-btn-padding-y:9px;--bs-btn-font-size:.92rem;--bs-btn-font-weight:600;--bs-btn-line-height:normal;--bs-btn-border-width:1.5px;--bs-btn-border-radius:999px;
  --bs-btn-color:var(--rf-ink);--bs-btn-bg:#fff;--bs-btn-border-color:var(--rf-edge);--bs-btn-hover-color:var(--rf-ink);--bs-btn-hover-bg:#fff;--bs-btn-hover-border-color:var(--rf-ink);
  --bs-btn-active-color:#fff;--bs-btn-active-bg:var(--rf-ink);--bs-btn-active-border-color:var(--rf-ink);display:inline-flex;align-items:center;gap:7px;transition:background-color .15s,color .15s,border-color .15s}
.rf-chips .btn svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;stroke-linejoin:round;display:none}
.rf-chips .btn-check:checked+.btn svg{display:block}
.rf-chips .btn{padding-left:14px}

.rf-actions{display:flex;flex-wrap:wrap;align-items:center;gap:12px 18px;margin-top:26px}
.rf .btn-dark{--bs-btn-padding-x:24px;--bs-btn-padding-y:13px;--bs-btn-font-size:.98rem;--bs-btn-font-weight:700;--bs-btn-line-height:normal;--bs-btn-border-radius:12px;--bs-btn-border-width:0;
  --bs-btn-bg:var(--rf-ink);--bs-btn-hover-bg:#3a4049;--bs-btn-active-bg:#3a4049;--bs-btn-focus-box-shadow:none;transition:background-color .15s}
.rf .btn-dark:focus-visible{outline:3px solid var(--rf-focus);outline-offset:3px}
.rf-note{flex:1 1 220px;font-size:.84rem;color:var(--rf-muted)}
.rf-status{margin-top:16px;font-size:.92rem;font-weight:600;color:var(--rf-good)}
.rf-status:empty{display:none}
.rf-compact{max-width:520px}
.rf-compact .rf-product{padding-bottom:18px;margin-bottom:4px}
@media (max-width:480px){.rf{padding:22px 18px}.rf{--rf-size:30px}}
@media (prefers-reduced-motion:reduce){.rf-star svg,.rf .form-control,.rf .btn{transition:none}.rf .rf-star:hover svg{transform:none}}
JavaScript
// Review form validation: one function per form, no library. Fields to check carry data-check="error message".
document.querySelectorAll('form[data-review]').forEach(function (form) {
  form.noValidate = true; // we show our own messages; without this script the browser's checks run instead
  var text = form.querySelector('textarea[maxlength][minlength]'), count = form.querySelector('[data-count]');
  if (text && count) text.addEventListener('input', function () { count.textContent = text.value.length; });

  function problem(field) {
    if (field.matches('fieldset')) { var c = field.querySelector('input:checked'); return !c || c.value === ''; }
    var v = field.value.trim();
    return (field.required && !v) || (field.minLength > 0 && v.length < field.minLength);
  }
  function check(field) {
    var bad = problem(field), err = document.getElementById(field.getAttribute('aria-describedby').split(' ').pop());
    err.textContent = bad ? field.dataset.check : '';
    if (bad) field.setAttribute('aria-invalid', 'true'); else field.removeAttribute('aria-invalid');
    field.classList.toggle('is-invalid', bad); // Bootstrap's invalid border
    return bad;
  }
  // once a field has been flagged, re-check it as the user fixes it
  form.addEventListener('input', function (e) { var f = e.target.closest('[data-check]'); if (f && f.hasAttribute('aria-invalid')) check(f); });
  form.addEventListener('change', function (e) { var f = e.target.closest('[data-check]'); if (f && f.hasAttribute('aria-invalid')) check(f); });

  form.addEventListener('submit', function (e) {
    e.preventDefault();
    var status = form.querySelector('[role="status"]'), first = null;
    form.querySelectorAll('[data-check]').forEach(function (f) { if (check(f) && !first) first = f; });
    if (first) { status.textContent = ''; (first.matches('fieldset') ? first.querySelector('input:checked') : first).focus(); return; }
    status.textContent = form.dataset.done;
    form.reset(); if (count) count.textContent = '0';
  });
});

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<svg width="0" height="0" class="absolute" aria-hidden="true" focusable="false"><symbol id="rf-star" viewBox="0 0 20 20"><path d="M9.05 2.93c.3-.92 1.6-.92 1.9 0l1.07 3.29a1 1 0 0 0 .95.69h3.46c.97 0 1.37 1.24.59 1.81l-2.8 2.03a1 1 0 0 0-.36 1.12l1.07 3.3c.3.91-.76 1.68-1.54 1.11l-2.8-2.03a1 1 0 0 0-1.18 0l-2.8 2.03c-.78.57-1.84-.2-1.54-1.11l1.07-3.3a1 1 0 0 0-.36-1.12L2.98 8.72c-.78-.57-.38-1.81.59-1.81h3.46a1 1 0 0 0 .95-.69z"/></symbol></svg>

<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#5d6573] uppercase">Product review form</span>
<form class="rounded-[22px] border border-rf-line bg-white p-[22px_18px] [--si-size:30px] min-[481px]:p-7 min-[481px]:[--si-size:34px] text-rf-ink" data-review data-done="Thank you. Your review has been sent and will appear after a quick check." action="#" method="post" aria-labelledby="rf-p1">
  <div class="mb-[22px] flex items-center gap-3.5 border-b border-rf-line pb-[22px]"><span class="grid size-14 flex-none place-items-center rounded-[14px] bg-linear-140 from-[#1f2328] to-[#4b5361] text-[#fbbf24]" aria-hidden="true"><svg class="size-7 fill-none stroke-current stroke-[1.6] [stroke-linecap:round] [stroke-linejoin:round]" viewBox="0 0 24 24"><path d="M10 2v2M14 2v2M6 2v2"/><path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1"/></svg></span>
    <div><h2 id="rf-p1" class="text-[1.1rem] font-extrabold">Review the Larkspur Pour-Over Kettle</h2><p class="mt-[3px] text-[.88rem] text-rf-muted">Matte black · 1 litre · bought 3 weeks ago</p></div></div>

  <fieldset class="mt-[22px] m-0 min-w-0 border-0 p-0" data-check="Choose a star rating from 1 to 5." aria-describedby="rf-rating-err">
    <legend class="block text-[.95rem] font-bold p-0">Overall rating<span class="ml-1.5 text-[.8rem] font-semibold text-rf-muted">Required</span></legend>
    <div class="star-rate relative mt-2 flex flex-wrap items-center">
      <input type="radio" name="rating" id="rf-rating-0" value="" checked><label data-clear class="order-1 ml-2.5 rounded-full border border-rf-line bg-white px-3 py-1.5 text-[.82rem] font-semibold text-rf-muted hover:border-rf-edge hover:text-rf-ink" for="rf-rating-0">Clear<span class="sr-only"> rating</span></label>
      <input type="radio" name="rating" id="rf-rating-1" value="1"><label data-star class="relative block rounded-[10px] p-1" for="rf-rating-1"><svg aria-hidden="true"><use href="#rf-star"/></svg><span class="sr-only">1 star</span></label>
      <input type="radio" name="rating" id="rf-rating-2" value="2"><label data-star class="relative block rounded-[10px] p-1" for="rf-rating-2"><svg aria-hidden="true"><use href="#rf-star"/></svg><span class="sr-only">2 stars</span></label>
      <input type="radio" name="rating" id="rf-rating-3" value="3"><label data-star class="relative block rounded-[10px] p-1" for="rf-rating-3"><svg aria-hidden="true"><use href="#rf-star"/></svg><span class="sr-only">3 stars</span></label>
      <input type="radio" name="rating" id="rf-rating-4" value="4"><label data-star class="relative block rounded-[10px] p-1" for="rf-rating-4"><svg aria-hidden="true"><use href="#rf-star"/></svg><span class="sr-only">4 stars</span></label>
      <input type="radio" name="rating" id="rf-rating-5" value="5"><label data-star class="relative block rounded-[10px] p-1" for="rf-rating-5"><svg aria-hidden="true"><use href="#rf-star"/></svg><span class="sr-only">5 stars</span></label>
      <span data-word class="order-2 ml-3.5 text-[.92rem] text-rf-muted [&_b]:text-rf-ink" aria-hidden="true"><span data-v="">Not rated</span><span data-v="1"><b>Poor</b></span><span data-v="2"><b>Fair</b></span><span data-v="3"><b>Good</b></span><span data-v="4"><b>Very good</b></span><span data-v="5"><b>Excellent</b></span></span>
    </div>
    <p class="mt-2 flex items-start gap-[7px] text-[.88rem] font-semibold text-rf-bad empty:hidden before:mt-px before:grid before:size-[18px] before:flex-none before:place-items-center before:rounded-full before:bg-rf-bad before:text-[.75rem] before:font-extrabold before:text-white before:content-['!']" id="rf-rating-err"></p>
  </fieldset>

  <div class="mt-[22px]">
    <label class="block text-[.95rem] font-bold" for="rf-title">Review title<span class="ml-1.5 text-[.8rem] font-semibold text-rf-muted">Required</span></label>
    <input class="mt-2 block w-full rounded-xl border-[1.5px] border-rf-edge bg-white px-3.5 py-3 text-base text-rf-ink transition-colors hover:border-rf-ink focus-visible:border-rf-ink focus-visible:outline-3 focus-visible:outline-offset-1 focus-visible:outline-rf-focus aria-invalid:border-rf-bad aria-invalid:bg-[#fffafa] motion-reduce:transition-none" id="rf-title" name="title" type="text" maxlength="80" required placeholder="Pours like a dream" aria-describedby="rf-title-err" data-check="Give your review a title.">
    <p class="mt-2 flex items-start gap-[7px] text-[.88rem] font-semibold text-rf-bad empty:hidden before:mt-px before:grid before:size-[18px] before:flex-none before:place-items-center before:rounded-full before:bg-rf-bad before:text-[.75rem] before:font-extrabold before:text-white before:content-['!']" id="rf-title-err"></p>
  </div>

  <div class="mt-[22px]">
    <label class="block text-[.95rem] font-bold" for="rf-body">Your review<span class="ml-1.5 text-[.8rem] font-semibold text-rf-muted">Required</span></label>
    <p class="mt-1 text-[.85rem] text-rf-muted" id="rf-body-hint">What did you like or dislike? At least 30 characters.</p>
    <textarea class="mt-2 block w-full rounded-xl border-[1.5px] border-rf-edge bg-white px-3.5 py-3 text-base text-rf-ink transition-colors hover:border-rf-ink focus-visible:border-rf-ink focus-visible:outline-3 focus-visible:outline-offset-1 focus-visible:outline-rf-focus aria-invalid:border-rf-bad aria-invalid:bg-[#fffafa] motion-reduce:transition-none min-h-[140px] resize-y leading-normal" id="rf-body" name="review" minlength="30" maxlength="1000" required aria-describedby="rf-body-hint rf-body-err" data-check="Write at least 30 characters about the product."></textarea>
    <p class="mt-1.5 text-right text-[.82rem] text-rf-muted tabular-nums" aria-hidden="true"><span data-count>0</span> / 1000</p>
    <p class="mt-2 flex items-start gap-[7px] text-[.88rem] font-semibold text-rf-bad empty:hidden before:mt-px before:grid before:size-[18px] before:flex-none before:place-items-center before:rounded-full before:bg-rf-bad before:text-[.75rem] before:font-extrabold before:text-white before:content-['!']" id="rf-body-err"></p>
  </div>

  <div class="grid grid-cols-1 gap-x-[18px] min-[600px]:grid-cols-2">
    <fieldset class="mt-[22px] m-0 min-w-0 border-0 p-0">
      <legend class="block text-[.95rem] font-bold p-0">Would you recommend it?</legend>
      <div class="mt-2 flex flex-wrap gap-2">
        <div class="relative"><input class="peer sr-only" type="radio" name="recommend" id="rf-rec-yes" value="yes"><label class="inline-flex cursor-pointer items-center gap-[7px] rounded-full border-[1.5px] border-rf-edge bg-white px-4 py-[9px] text-[.92rem] font-semibold transition-colors hover:border-rf-ink motion-reduce:transition-none peer-checked:border-rf-ink peer-checked:bg-rf-ink peer-checked:text-white peer-focus-visible:outline-3 peer-focus-visible:outline-offset-1 peer-focus-visible:outline-rf-focus" for="rf-rec-yes">Yes</label></div>
        <div class="relative"><input class="peer sr-only" type="radio" name="recommend" id="rf-rec-no" value="no"><label class="inline-flex cursor-pointer items-center gap-[7px] rounded-full border-[1.5px] border-rf-edge bg-white px-4 py-[9px] text-[.92rem] font-semibold transition-colors hover:border-rf-ink motion-reduce:transition-none peer-checked:border-rf-ink peer-checked:bg-rf-ink peer-checked:text-white peer-focus-visible:outline-3 peer-focus-visible:outline-offset-1 peer-focus-visible:outline-rf-focus" for="rf-rec-no">No</label></div>
      </div>
    </fieldset>
    <div class="mt-[22px]">
      <label class="block text-[.95rem] font-bold" for="rf-name">Display name<span class="ml-1.5 text-[.8rem] font-semibold text-rf-muted">Required</span></label>
      <input class="mt-2 block w-full rounded-xl border-[1.5px] border-rf-edge bg-white px-3.5 py-3 text-base text-rf-ink transition-colors hover:border-rf-ink focus-visible:border-rf-ink focus-visible:outline-3 focus-visible:outline-offset-1 focus-visible:outline-rf-focus aria-invalid:border-rf-bad aria-invalid:bg-[#fffafa] motion-reduce:transition-none" id="rf-name" name="name" type="text" autocomplete="nickname" required aria-describedby="rf-name-err" data-check="Enter the name to show with your review.">
      <p class="mt-2 flex items-start gap-[7px] text-[.88rem] font-semibold text-rf-bad empty:hidden before:mt-px before:grid before:size-[18px] before:flex-none before:place-items-center before:rounded-full before:bg-rf-bad before:text-[.75rem] before:font-extrabold before:text-white before:content-['!']" id="rf-name-err"></p>
    </div>
  </div>

  <div class="mt-[26px] flex flex-wrap items-center gap-x-[18px] gap-y-3"><button class="cursor-pointer rounded-xl bg-rf-ink px-6 py-[13px] text-[.98rem] font-bold text-white transition-colors hover:bg-[#3a4049] focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-rf-focus motion-reduce:transition-none" type="submit">Post review</button>
    <p class="flex-[1_1_220px] text-[.84rem] text-rf-muted">Reviews appear after a quick check, usually within a day.</p></div>
  <p class="mt-4 text-[.92rem] font-semibold text-rf-good empty:hidden" role="status"></p>
</form></div>

<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#5d6573] uppercase">Quick rating after delivery</span>
<form class="rounded-[22px] border border-rf-line bg-white p-[22px_18px] [--si-size:30px] min-[481px]:p-7 min-[481px]:[--si-size:34px] text-rf-ink max-w-[520px]" data-review data-done="Thank you. Your delivery rating has been sent." action="#" method="post" aria-labelledby="rf-p2">
  <div class="mb-1 border-b border-rf-line pb-[18px]"><h2 id="rf-p2" class="text-[1.1rem] font-extrabold">How did order #48213 go?</h2><p class="mt-[3px] text-[.88rem] text-rf-muted">Delivered on Tuesday 14 October</p></div>

  <fieldset class="mt-[22px] m-0 min-w-0 border-0 p-0" data-check="Choose a star rating for the delivery." aria-describedby="rf-del-err">
    <legend class="block text-[.95rem] font-bold p-0">Delivery rating<span class="ml-1.5 text-[.8rem] font-semibold text-rf-muted">Required</span></legend>
    <div class="star-rate relative mt-2 flex flex-wrap items-center">
      <input type="radio" name="delivery" id="rf-del-0" value="" checked><label data-clear class="order-1 ml-2.5 rounded-full border border-rf-line bg-white px-3 py-1.5 text-[.82rem] font-semibold text-rf-muted hover:border-rf-edge hover:text-rf-ink" for="rf-del-0">Clear<span class="sr-only"> rating</span></label>
      <input type="radio" name="delivery" id="rf-del-1" value="1"><label data-star class="relative block rounded-[10px] p-1" for="rf-del-1"><svg aria-hidden="true"><use href="#rf-star"/></svg><span class="sr-only">1 star</span></label>
      <input type="radio" name="delivery" id="rf-del-2" value="2"><label data-star class="relative block rounded-[10px] p-1" for="rf-del-2"><svg aria-hidden="true"><use href="#rf-star"/></svg><span class="sr-only">2 stars</span></label>
      <input type="radio" name="delivery" id="rf-del-3" value="3"><label data-star class="relative block rounded-[10px] p-1" for="rf-del-3"><svg aria-hidden="true"><use href="#rf-star"/></svg><span class="sr-only">3 stars</span></label>
      <input type="radio" name="delivery" id="rf-del-4" value="4"><label data-star class="relative block rounded-[10px] p-1" for="rf-del-4"><svg aria-hidden="true"><use href="#rf-star"/></svg><span class="sr-only">4 stars</span></label>
      <input type="radio" name="delivery" id="rf-del-5" value="5"><label data-star class="relative block rounded-[10px] p-1" for="rf-del-5"><svg aria-hidden="true"><use href="#rf-star"/></svg><span class="sr-only">5 stars</span></label>
    </div>
    <p class="mt-2 flex items-start gap-[7px] text-[.88rem] font-semibold text-rf-bad empty:hidden before:mt-px before:grid before:size-[18px] before:flex-none before:place-items-center before:rounded-full before:bg-rf-bad before:text-[.75rem] before:font-extrabold before:text-white before:content-['!']" id="rf-del-err"></p>
  </fieldset>

  <fieldset class="mt-[22px] m-0 min-w-0 border-0 p-0">
    <legend class="block text-[.95rem] font-bold p-0">What went well?</legend>
    <div class="mt-2 flex flex-wrap gap-2">
      <div class="group relative"><input class="peer sr-only" type="checkbox" name="went_well" id="rf-w1" value="on-time"><label class="inline-flex cursor-pointer items-center gap-[7px] rounded-full border-[1.5px] border-rf-edge bg-white px-4 py-[9px] text-[.92rem] font-semibold transition-colors hover:border-rf-ink motion-reduce:transition-none peer-checked:border-rf-ink peer-checked:bg-rf-ink peer-checked:text-white peer-focus-visible:outline-3 peer-focus-visible:outline-offset-1 peer-focus-visible:outline-rf-focus pl-3.5" for="rf-w1"><svg class="hidden size-4 fill-none stroke-current stroke-[2.4] [stroke-linecap:round] [stroke-linejoin:round] group-has-[:checked]:block" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8.5l3 3 7-7"/></svg>Arrived on time</label></div>
      <div class="group relative"><input class="peer sr-only" type="checkbox" name="went_well" id="rf-w2" value="packaging"><label class="inline-flex cursor-pointer items-center gap-[7px] rounded-full border-[1.5px] border-rf-edge bg-white px-4 py-[9px] text-[.92rem] font-semibold transition-colors hover:border-rf-ink motion-reduce:transition-none peer-checked:border-rf-ink peer-checked:bg-rf-ink peer-checked:text-white peer-focus-visible:outline-3 peer-focus-visible:outline-offset-1 peer-focus-visible:outline-rf-focus pl-3.5" for="rf-w2"><svg class="hidden size-4 fill-none stroke-current stroke-[2.4] [stroke-linecap:round] [stroke-linejoin:round] group-has-[:checked]:block" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8.5l3 3 7-7"/></svg>Well packed</label></div>
      <div class="group relative"><input class="peer sr-only" type="checkbox" name="went_well" id="rf-w3" value="as-pictured"><label class="inline-flex cursor-pointer items-center gap-[7px] rounded-full border-[1.5px] border-rf-edge bg-white px-4 py-[9px] text-[.92rem] font-semibold transition-colors hover:border-rf-ink motion-reduce:transition-none peer-checked:border-rf-ink peer-checked:bg-rf-ink peer-checked:text-white peer-focus-visible:outline-3 peer-focus-visible:outline-offset-1 peer-focus-visible:outline-rf-focus pl-3.5" for="rf-w3"><svg class="hidden size-4 fill-none stroke-current stroke-[2.4] [stroke-linecap:round] [stroke-linejoin:round] group-has-[:checked]:block" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8.5l3 3 7-7"/></svg>Matches the photos</label></div>
      <div class="group relative"><input class="peer sr-only" type="checkbox" name="went_well" id="rf-w4" value="courier"><label class="inline-flex cursor-pointer items-center gap-[7px] rounded-full border-[1.5px] border-rf-edge bg-white px-4 py-[9px] text-[.92rem] font-semibold transition-colors hover:border-rf-ink motion-reduce:transition-none peer-checked:border-rf-ink peer-checked:bg-rf-ink peer-checked:text-white peer-focus-visible:outline-3 peer-focus-visible:outline-offset-1 peer-focus-visible:outline-rf-focus pl-3.5" for="rf-w4"><svg class="hidden size-4 fill-none stroke-current stroke-[2.4] [stroke-linecap:round] [stroke-linejoin:round] group-has-[:checked]:block" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8.5l3 3 7-7"/></svg>Friendly courier</label></div>
    </div>
  </fieldset>

  <div class="mt-[22px]">
    <label class="block text-[.95rem] font-bold" for="rf-note">Anything else? <span class="ml-1.5 text-[.8rem] font-semibold text-rf-muted">Optional</span></label>
    <textarea class="mt-2 block w-full rounded-xl border-[1.5px] border-rf-edge bg-white px-3.5 py-3 text-base text-rf-ink transition-colors hover:border-rf-ink focus-visible:border-rf-ink focus-visible:outline-3 focus-visible:outline-offset-1 focus-visible:outline-rf-focus aria-invalid:border-rf-bad aria-invalid:bg-[#fffafa] motion-reduce:transition-none min-h-24 resize-y leading-normal" id="rf-note" name="note" maxlength="300" rows="3"></textarea>
  </div>

  <div class="mt-[26px] flex flex-wrap items-center gap-x-[18px] gap-y-3"><button class="cursor-pointer rounded-xl bg-rf-ink px-6 py-[13px] text-[.98rem] font-bold text-white transition-colors hover:bg-[#3a4049] focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-rf-focus motion-reduce:transition-none" type="submit">Send rating</button></div>
  <p class="mt-4 text-[.92rem] font-semibold text-rf-good empty:hidden" role="status"></p>
</form></div>
CSS
@theme {
  --color-rf-on: #fbbf24;
  --color-rf-on-edge: #b45309;
  --color-rf-off: #f3f1ed;
  --color-rf-edge: #878d98;
  --color-rf-ink: #1f2328;
  --color-rf-muted: #5b616e;
  --color-rf-line: #e8e3da;
  --color-rf-focus: #1d4ed8;
  --color-rf-bad: #b91c1c;
  --color-rf-good: #166534;
}
/* Star row: a native radio group in natural 1→5 order. "Clear" (value "") starts checked; every star is gold and the
   stars AFTER the checked radio turn grey. Put it on the element that holds the inputs and labels. */
@utility star-rate {
  pointer-events: none;
  & label { pointer-events: auto; cursor: pointer; }
  & input { position: absolute; top: 0; left: 0; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; }
  & label[data-star] svg { display: block; width: var(--si-size, 34px); height: var(--si-size, 34px); fill: var(--color-rf-on); stroke: var(--color-rf-on-edge); stroke-width: 1.1; stroke-linejoin: round; transition: fill .15s, stroke .15s, transform .15s; }
  & input:checked + label ~ label[data-star] svg { fill: var(--color-rf-off); stroke: var(--color-rf-edge); }
  @media (hover: hover) {
    &:hover label[data-star] svg { fill: var(--color-rf-on); stroke: var(--color-rf-on-edge); }
    &:hover label:hover ~ label[data-star] svg { fill: var(--color-rf-off); stroke: var(--color-rf-edge); }
    & label[data-star]:hover svg { transform: scale(1.12); }
  }
  & label[data-star]::after { content: ""; position: absolute; left: 50%; bottom: -4px; width: 14px; height: 3px; margin-left: -7px; border-radius: 2px; }
  & input:checked + label[data-star]::after { background: var(--color-rf-ink); }
  & input:checked + label[data-clear] { border-style: dashed; }
  & input:focus-visible + label { outline: 3px solid var(--color-rf-focus); outline-offset: 1px; }
  [aria-invalid="true"] & input ~ label[data-star][data-star] svg { stroke: var(--color-rf-bad); }
  & [data-word] span { display: none; }
  & input[value=""]:checked ~ [data-word] [data-v=""], & input[value="1"]:checked ~ [data-word] [data-v="1"], & input[value="2"]:checked ~ [data-word] [data-v="2"],
  & input[value="3"]:checked ~ [data-word] [data-v="3"], & input[value="4"]:checked ~ [data-word] [data-v="4"], & input[value="5"]:checked ~ [data-word] [data-v="5"] { display: inline; }
  @media (prefers-reduced-motion: reduce) { & label[data-star] svg { transition: none; } & label[data-star]:hover svg { transform: none; } }
}
JavaScript
// Review form validation: one function per form, no library. Fields to check carry data-check="error message".
document.querySelectorAll('form[data-review]').forEach(function (form) {
  form.noValidate = true; // we show our own messages; without this script the browser's checks run instead
  var text = form.querySelector('textarea[maxlength][minlength]'), count = form.querySelector('[data-count]');
  if (text && count) text.addEventListener('input', function () { count.textContent = text.value.length; });

  function problem(field) {
    if (field.matches('fieldset')) { var c = field.querySelector('input:checked'); return !c || c.value === ''; }
    var v = field.value.trim();
    return (field.required && !v) || (field.minLength > 0 && v.length < field.minLength);
  }
  function check(field) {
    var bad = problem(field), err = document.getElementById(field.getAttribute('aria-describedby').split(' ').pop());
    err.textContent = bad ? field.dataset.check : '';
    if (bad) field.setAttribute('aria-invalid', 'true'); else field.removeAttribute('aria-invalid');
    return bad;
  }
  // once a field has been flagged, re-check it as the user fixes it
  form.addEventListener('input', function (e) { var f = e.target.closest('[data-check]'); if (f && f.hasAttribute('aria-invalid')) check(f); });
  form.addEventListener('change', function (e) { var f = e.target.closest('[data-check]'); if (f && f.hasAttribute('aria-invalid')) check(f); });

  form.addEventListener('submit', function (e) {
    e.preventDefault();
    var status = form.querySelector('[role="status"]'), first = null;
    form.querySelectorAll('[data-check]').forEach(function (f) { if (check(f) && !first) first = f; });
    if (first) { status.textContent = ''; (first.matches('fieldset') ? first.querySelector('input:checked') : first).focus(); return; }
    status.textContent = form.dataset.done;
    form.reset(); if (count) count.textContent = '0';
  });
});

About this set

Review forms with a star rating, a title, a text field and validation. Product review form asks for an overall rating, a review title, the review itself with a character count, whether you would recommend the product, and a display name. Quick rating after delivery is a short card with a required delivery rating, four What went well chips and an optional note. The stars are a native radio group in a fieldset, using the same natural-order sibling technique as the star input set. About thirty lines of vanilla JavaScript handle validation: they switch off the browser’s bubbles, check each field on submit, write the message under the field, mark it aria-invalid, move focus to the first problem and re-check a field as soon as it is corrected. A rating counts as missing while the Clear option is selected, and the review needs at least 30 characters. On success a status message thanks the user and the form resets. Without the script, the browser’s own required checks still run.

What’s included

  • Required star rating inside a real form
  • Error messages under each field, linked with aria-describedby
  • Focus moves to the first field with a problem
  • Live character count for the review
  • Yes or No pills and checkbox chips
  • About thirty lines of vanilla JavaScript, no library

Accessibility

Every field has a visible label and the rating group has a legend. Errors are written as text under the field, joined to it with aria-describedby and flagged with aria-invalid, and focus moves to the first problem on submit. The success message is in a role=status region so screen readers announce it. Every control shows a 3px focus outline and transitions stop under prefers-reduced-motion.

Customise it

Change --rf-on, --rf-edge, --rf-bad, --rf-good and --rf-focus on .rf for colours and --rf-size for the stars. The Bootstrap version styles .form-control and .is-invalid with the same variables; in Tailwind edit the rf colours in @theme. Error messages live in each field's data-check attribute.