Open demo

CSS code

HTML
<div><span class="lbl">Guest comment form with hints, errors and a live count</span>
<form class="cf" data-comment-form data-success="Thanks. Your comment is awaiting moderation and will appear once approved." novalidate aria-labelledby="cf-a-title">
  <h2 class="cf-h" id="cf-a-title">Leave a reply</h2>
  <p class="cf-intro">Your email address will not be published. Fields marked * are required.</p>
  <div class="cf-grid">
      <div class="cf-f">
        <label class="cf-label" for="cf-a-name">Name <span class="cf-req" aria-hidden="true">*</span></label>
        <input class="cf-input" id="cf-a-name" name="name" autocomplete="name" required data-required data-msg="Enter your name." aria-describedby="cf-a-name-err">
        <p class="cf-err" id="cf-a-name-err"></p>
      </div>
      <div class="cf-f">
        <label class="cf-label" for="cf-a-email">Email <span class="cf-req" aria-hidden="true">*</span></label>
        <input class="cf-input" id="cf-a-email" type="email" name="email" autocomplete="email" required data-required data-msg="Enter an email address, like [email protected]." aria-describedby="cf-a-email-hint cf-a-email-err">
        <p class="cf-hint" id="cf-a-email-hint">Never shown publicly</p><p class="cf-err" id="cf-a-email-err"></p>
      </div>
      <div class="cf-f">
        <label class="cf-label" for="cf-a-site">Website <span class="cf-opt">(optional)</span></label>
        <input class="cf-input" id="cf-a-site" type="url" name="url" autocomplete="url" placeholder="https://">
      </div>
    <div class="cf-f full">
      <label class="cf-label" for="cf-a-body">Comment <span class="cf-req" aria-hidden="true">*</span></label>
      <textarea class="cf-input cf-text" id="cf-a-body" name="comment" rows="6" required data-required data-max="1500" data-msg="Write a comment before posting." aria-describedby="cf-a-body-hint cf-a-body-count cf-a-body-err"></textarea>
      <div class="cf-meta"><p class="cf-hint" id="cf-a-body-hint">Be kind and stay on topic. Comments with links are checked before they appear.</p><p class="cf-count" id="cf-a-body-count" data-count><span data-n>0</span> / 1,500<span class="cf-sr"> characters</span></p></div>
      <p class="cf-err" id="cf-a-body-err"></p>
      <span class="cf-sr" aria-live="polite" data-count-live></span>
    </div>
  </div>
  <div class="cf-actions">
    <div class="cf-check"><input class="cf-box" type="checkbox" id="cf-a-notify" name="notify" checked><label class="cf-box-l" for="cf-a-notify">Email me when someone replies</label></div>
    <button class="cf-btn" type="submit" data-idle="Post comment" data-sending="Posting…" data-sent="Posted"><span class="cf-spin" aria-hidden="true"></span><svg class="cf-done" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12 5 5 9-10"/></svg><span data-label>Post comment</span></button>
  </div>
  <p class="cf-status" role="status" data-status></p>
</form></div>

<div><span class="lbl">Signed-in comment box with a markdown toolbar</span>
<form class="cf" data-comment-form data-success="Your comment is live. Thanks for adding to the discussion." novalidate aria-label="Add a comment">
  <div class="cf-me"><span class="cf-av" aria-hidden="true">MK</span><p class="cf-me-t">Commenting as <b>Mara Kovač</b> · <a class="cf-link" href="#">Not you? Sign out</a></p></div>
  <label class="cf-sr" for="cf-b-body">Your comment</label>
  <div class="cf-editor">
    <div class="cf-tools" role="group" aria-label="Formatting">
      <button class="cf-tool" type="button" data-md="**|**|bold text"><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="M7 5h6a3.5 3.5 0 0 1 0 7H7zM7 12h7a3.5 3.5 0 0 1 0 7H7z"/></svg><span class="cf-sr">Bold</span></button><button class="cf-tool" type="button" data-md="_|_|italic text"><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="M10 5h8M6 19h8M15 5 9 19"/></svg><span class="cf-sr">Italic</span></button><button class="cf-tool" type="button" data-md="`|`|code"><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="m9 8-4 4 4 4M15 8l4 4-4 4"/></svg><span class="cf-sr">Inline code</span></button><button class="cf-tool" type="button" data-md="[|](https://)|link text"><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="M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1 1"/><path d="M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1-1"/></svg><span class="cf-sr">Link</span></button><button class="cf-tool" type="button" data-md="> ||quoted text"><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="M7 7h4v4c0 3-2 5-4 6M14 7h4v4c0 3-2 5-4 6"/></svg><span class="cf-sr">Quote</span></button><button class="cf-tool" type="button" data-md="- ||list item"><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="M9 6h11M9 12h11M9 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01"/></svg><span class="cf-sr">Bulleted list</span></button>
    </div>
    <textarea class="cf-area" id="cf-b-body" name="comment" rows="5" placeholder="What do you think of the new cycle lanes?" required data-required data-max="500" data-msg="Write a comment before posting." aria-describedby="cf-b-hint cf-b-count cf-b-err"></textarea>
  </div>
  <div class="cf-meta mt"><p class="cf-hint" id="cf-b-hint">Markdown works: **bold**, _italic_, `code` and > quote.</p><p class="cf-count" id="cf-b-count" data-count><span data-n>0</span> / 500<span class="cf-sr"> characters</span></p></div>
  <p class="cf-err" id="cf-b-err"></p>
  <span class="cf-sr" aria-live="polite" data-count-live></span>
  <div class="cf-actions end"><button class="cf-btn ghost" type="reset">Cancel</button><button class="cf-btn" type="submit" data-idle="Comment" data-sending="Posting…" data-sent="Posted"><span class="cf-spin" aria-hidden="true"></span><svg class="cf-done" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12 5 5 9-10"/></svg><span data-label>Comment</span></button></div>
  <p class="cf-status" role="status" data-status></p>
</form></div>

<div><span class="lbl">Signed-out prompt</span>
<section class="cf-gate" aria-labelledby="cf-c-title">
  <span class="cf-gate-i" aria-hidden="true"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a8 8 0 0 1-11.6 7.1L4 20l1-4.6A8 8 0 1 1 21 12Z"/><path d="M8.5 11h7M8.5 14h4"/></svg></span>
  <div class="cf-gate-t"><h2 class="cf-h sm" id="cf-c-title">Join the discussion</h2><p class="cf-intro">Sign in to comment. 142 readers have shared their take on this guide.</p></div>
  <div class="cf-gate-a"><a class="cf-btn" href="#">Sign in</a><a class="cf-btn ghost" href="#">Create account</a></div>
</section></div>
CSS
/* Comment forms: labelled fields with hints and errors, a live character count, markdown shortcuts and submit states (idle, posting, posted). */
.cf,.cf-gate{--cf-accent:#4338ca;--cf-accent-dark:#3730a3;--cf-soft:#eef2ff;--cf-ink:#1c1f2e;--cf-muted:#545a6e;--cf-line:#e2e4ee;--cf-field:#8a90a6;--cf-err:#b42318;--cf-ok:#166534;--cf-warn:#92400e}
.cf{padding:28px;border:1px solid var(--cf-line);border-radius:18px;background:#fff;color:var(--cf-ink);font-size:.94rem;line-height:1.5;box-shadow:0 18px 40px -28px rgba(28,31,46,.35)}
.cf-sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
:where(.cf,.cf-gate) :is(button,input,textarea){font:inherit;color:inherit}
.cf :where(h2,p){margin:0}
.cf-h{font-size:1.25rem;font-weight:700;letter-spacing:-.01em;line-height:1.3}
.cf-h.sm{font-size:1.05rem}
.cf-intro{margin-top:4px;color:var(--cf-muted);font-size:.86rem}
.cf-label{font-size:.84rem;font-weight:600}
.cf-req{color:var(--cf-err)}
.cf-opt{font-weight:400;color:var(--cf-muted)}
.cf-hint{font-size:.78rem;color:var(--cf-muted)}
.cf-err{font-size:.8rem;font-weight:600;color:var(--cf-err)}
.cf-err:empty{display:none}
.cf-meta{display:flex;justify-content:space-between;align-items:baseline;gap:12px}
.cf-meta.mt{margin-top:8px}
.cf-count{margin-left:auto;font-size:.78rem;color:var(--cf-muted);font-variant-numeric:tabular-nums;white-space:nowrap}
.cf-count[data-level=near]{color:var(--cf-warn);font-weight:600}
.cf-count[data-level=over]{color:var(--cf-err);font-weight:700}
.cf-actions{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:12px;margin-top:20px}
.cf-actions.end{justify-content:flex-end;gap:10px;margin-top:16px}
.cf-done{display:none}
[data-state=sent] .cf-done{display:inline-block}
.cf-me{display:flex;align-items:center;gap:10px;margin-bottom:14px}
.cf-av{flex:none;display:grid;place-items:center;width:36px;height:36px;border-radius:50%;background:#fde68a;color:#713f12;font-size:.74rem;font-weight:700}
.cf-me-t{font-size:.88rem;color:var(--cf-muted)}
.cf-me-t b{color:var(--cf-ink)}
.cf-link{color:var(--cf-accent);font-weight:600;text-decoration:underline;text-underline-offset:2px}
.cf-link:hover{color:var(--cf-accent-dark)}
.cf-editor{overflow:hidden;border:1.5px solid var(--cf-field);border-radius:12px;background:#fff;transition:border-color .15s,box-shadow .15s}
.cf-editor:focus-within{border-color:var(--cf-accent);box-shadow:0 0 0 3px rgba(67,56,202,.25)}
.cf-editor:has([aria-invalid=true]){border-color:var(--cf-err)}
.cf-tools{display:flex;flex-wrap:wrap;gap:2px;padding:6px;border-bottom:1px solid var(--cf-line);background:#f8f8fb}
.cf-gate{display:flex;flex-wrap:wrap;align-items:center;gap:18px;padding:24px;border:1px dashed var(--cf-field);border-radius:18px;background:#fff;color:var(--cf-ink);font-size:.94rem}
.cf-gate :where(h2,p){margin:0}
.cf-gate-i{flex:none;display:grid;place-items:center;width:48px;height:48px;border-radius:14px;background:var(--cf-soft);color:var(--cf-accent)}
.cf-gate-t{flex:1;min-width:200px}
.cf-gate-a{display:flex;flex-wrap:wrap;gap:10px}
.cf-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;margin-top:20px}
.cf-f{display:flex;flex-direction:column;gap:6px;min-width:0}
.cf-f.full{grid-column:1/-1}
.cf-input{width:100%;padding:10px 12px;border:1.5px solid var(--cf-field);border-radius:10px;background:#fff;color:var(--cf-ink);transition:border-color .15s,box-shadow .15s}
.cf-input::placeholder,.cf-area::placeholder{color:#6b7085}
.cf-input:focus{border-color:var(--cf-accent);outline:2px solid transparent;box-shadow:0 0 0 3px rgba(67,56,202,.25)}
.cf-input[aria-invalid=true]{border-color:var(--cf-err)}
.cf-text{min-height:140px;resize:vertical}
.cf-check{display:flex;align-items:center;gap:10px;font-size:.88rem}
.cf-box{width:18px;height:18px;accent-color:var(--cf-accent);cursor:pointer}
.cf-box-l{cursor:pointer}
.cf-btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:42px;padding:0 20px;border:0;border-radius:999px;background:var(--cf-accent);color:#fff;font-weight:600;text-decoration:none;cursor:pointer;transition:background-color .15s}
.cf-btn:hover{background:var(--cf-accent-dark)}
.cf-btn.ghost{border:1.5px solid var(--cf-line);background:transparent;color:var(--cf-ink)}
.cf-btn.ghost:hover{background:var(--cf-soft)}
.cf-btn[aria-disabled=true]{cursor:progress}
[data-state=sent] .cf-btn[type=submit]{background:var(--cf-ok)}
:is(.cf,.cf-gate) :is(.cf-btn,.cf-box,.cf-link):focus-visible{outline:2px solid var(--cf-accent);outline-offset:2px}
.cf-spin{display:none;width:16px;height:16px;border:2px solid rgba(255,255,255,.4);border-top-color:#fff;border-radius:50%;animation:cf-spin .7s linear infinite}
[data-state=sending] .cf-spin{display:inline-block}
@keyframes cf-spin{to{transform:rotate(360deg)}}
.cf-tool{display:grid;place-items:center;width:34px;height:34px;padding:0;border:0;border-radius:8px;background:transparent;color:var(--cf-muted);cursor:pointer;transition:background-color .15s,color .15s}
.cf-tool:hover{background:var(--cf-soft);color:var(--cf-accent-dark)}
.cf-tool:focus-visible{outline:2px solid var(--cf-accent);outline-offset:-2px}
.cf-area{display:block;width:100%;min-height:124px;padding:12px 14px;border:0;background:#fff;color:var(--cf-ink);resize:vertical}
.cf-area:focus{outline:none}
.cf-status{margin-top:16px;padding:10px 14px;border:1px solid #bbf7d0;border-radius:12px;background:#f0fdf4;color:var(--cf-ok);font-size:.88rem;font-weight:600}
.cf-status:empty{display:none}
@media (max-width:640px){.cf{padding:20px}}
@media (max-width:575.98px){.cf-grid{grid-template-columns:1fr}}
@media (prefers-reduced-motion:reduce){.cf-spin{animation:none}.cf *,.cf-gate *{transition:none}}
JavaScript
/* Comment forms: live character count (announced only when it nears or passes the limit), markdown shortcuts,
   validation that focuses the first problem, and idle / posting / posted states. Frontend only: swap the timeout for your fetch(). */
document.querySelectorAll('[data-comment-form]').forEach(function (form) {
  var area = form.querySelector('textarea'), count = form.querySelector('[data-count]'), live = form.querySelector('[data-count-live]');
  var btn = form.querySelector('[type=submit]'), label = btn.querySelector('[data-label]'), status = form.querySelector('[data-status]');
  var max = +area.dataset.max, level = '';
  var fmt = function (n) { return n.toLocaleString('en-US'); };
  function update() {
    var left = max - area.value.length;
    count.querySelector('[data-n]').textContent = fmt(area.value.length);
    var now = left < 0 ? 'over' : left <= max * 0.1 ? 'near' : '';
    count.dataset.level = now;
    if (now !== level) { level = now; live.textContent = now === 'over' ? fmt(-left) + ' characters over the limit' : now ? fmt(left) + ' characters left' : ''; }
  }
  function problem(f) {
    var v = f.value.trim();
    if (!v || (f.type === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v))) return f.dataset.msg;
    if (f === area && f.value.length > max) return 'Shorten your comment to ' + fmt(max) + ' characters or fewer.';
    return '';
  }
  function showError(f, msg) {
    var err = document.getElementById(f.id + '-err');
    if (err) err.textContent = msg;
    if (msg) f.setAttribute('aria-invalid', 'true'); else f.removeAttribute('aria-invalid');
  }
  function setState(s) {
    form.dataset.state = s; label.textContent = btn.dataset[s || 'idle'];
    if (s) btn.setAttribute('aria-disabled', 'true'); else btn.removeAttribute('aria-disabled');
  }
  form.querySelectorAll('[data-required]').forEach(function (f) {
    f.addEventListener('input', function () { if (f.hasAttribute('aria-invalid')) showError(f, problem(f)); });
  });
  area.addEventListener('input', update);
  form.querySelectorAll('[data-md]').forEach(function (b) {
    b.addEventListener('click', function () {
      var p = b.dataset.md.split('|'), s = area.selectionStart, e = area.selectionEnd, text = area.value.slice(s, e) || p[2];
      var before = p[0]; if (!p[1] && s > 0 && area.value[s - 1] !== '\n') before = '\n' + before;
      area.setRangeText(before + text + p[1], s, e, 'end');
      area.focus(); area.setSelectionRange(s + before.length, s + before.length + text.length); update();
    });
  });
  form.addEventListener('reset', function () {
    setTimeout(function () { update(); form.querySelectorAll('[aria-invalid]').forEach(function (f) { showError(f, ''); }); });
  });
  form.addEventListener('submit', function (e) {
    e.preventDefault();
    if (form.dataset.state) return;
    var first = null;
    form.querySelectorAll('[data-required]').forEach(function (f) { var m = problem(f); showError(f, m); if (m && !first) first = f; });
    if (first) { first.focus(); return; }
    setState('sending'); status.textContent = '';
    setTimeout(function () {
      area.value = ''; update(); setState('sent'); status.textContent = form.dataset.success;
      setTimeout(function () { setState(''); }, 2400);
    }, 1100);
  });
  update();
});

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div><span class="lbl">Guest comment form with hints, errors and a live count</span>
<form class="card cf" data-comment-form data-success="Thanks. Your comment is awaiting moderation and will appear once approved." novalidate aria-labelledby="cf-a-title">
  <h2 class="cf-h" id="cf-a-title">Leave a reply</h2>
  <p class="cf-intro">Your email address will not be published. Fields marked * are required.</p>
  <div class="row g-3 cf-grid">
      <div class="col-sm-4 cf-f">
        <label class="form-label cf-label" for="cf-a-name">Name <span class="cf-req" aria-hidden="true">*</span></label>
        <input class="form-control cf-input" id="cf-a-name" name="name" autocomplete="name" required data-required data-msg="Enter your name." aria-describedby="cf-a-name-err">
        <p class="invalid-feedback cf-err" id="cf-a-name-err"></p>
      </div>
      <div class="col-sm-4 cf-f">
        <label class="form-label cf-label" for="cf-a-email">Email <span class="cf-req" aria-hidden="true">*</span></label>
        <input class="form-control cf-input" id="cf-a-email" type="email" name="email" autocomplete="email" required data-required data-msg="Enter an email address, like [email protected]." aria-describedby="cf-a-email-hint cf-a-email-err">
        <p class="form-text cf-hint" id="cf-a-email-hint">Never shown publicly</p><p class="invalid-feedback cf-err" id="cf-a-email-err"></p>
      </div>
      <div class="col-sm-4 cf-f">
        <label class="form-label cf-label" for="cf-a-site">Website <span class="cf-opt">(optional)</span></label>
        <input class="form-control cf-input" id="cf-a-site" type="url" name="url" autocomplete="url" placeholder="https://">
      </div>
    <div class="col-12 cf-f">
      <label class="form-label cf-label" for="cf-a-body">Comment <span class="cf-req" aria-hidden="true">*</span></label>
      <textarea class="form-control cf-input cf-text" id="cf-a-body" name="comment" rows="6" required data-required data-max="1500" data-msg="Write a comment before posting." aria-describedby="cf-a-body-hint cf-a-body-count cf-a-body-err"></textarea>
      <div class="cf-meta"><p class="form-text cf-hint" id="cf-a-body-hint">Be kind and stay on topic. Comments with links are checked before they appear.</p><p class="cf-count" id="cf-a-body-count" data-count><span data-n>0</span> / 1,500<span class="visually-hidden"> characters</span></p></div>
      <p class="invalid-feedback cf-err" id="cf-a-body-err"></p>
      <span class="visually-hidden" aria-live="polite" data-count-live></span>
    </div>
  </div>
  <div class="cf-actions">
    <div class="form-check cf-check"><input class="form-check-input cf-box" type="checkbox" id="cf-a-notify" name="notify" checked><label class="form-check-label" for="cf-a-notify">Email me when someone replies</label></div>
    <button class="btn cf-btn" type="submit" data-idle="Post comment" data-sending="Posting…" data-sent="Posted"><span class="spinner-border spinner-border-sm cf-spin" aria-hidden="true"></span><svg class="cf-done" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12 5 5 9-10"/></svg><span data-label>Post comment</span></button>
  </div>
  <p class="alert alert-success cf-status" role="status" data-status></p>
</form></div>

<div><span class="lbl">Signed-in comment box with a markdown toolbar</span>
<form class="card cf" data-comment-form data-success="Your comment is live. Thanks for adding to the discussion." novalidate aria-label="Add a comment">
  <div class="cf-me"><span class="cf-av" aria-hidden="true">MK</span><p class="cf-me-t">Commenting as <b>Mara Kovač</b> · <a class="cf-link" href="#">Not you? Sign out</a></p></div>
  <label class="visually-hidden" for="cf-b-body">Your comment</label>
  <div class="cf-editor">
    <div class="btn-toolbar cf-tools" role="group" aria-label="Formatting">
      <button class="btn cf-tool" type="button" data-md="**|**|bold text"><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="M7 5h6a3.5 3.5 0 0 1 0 7H7zM7 12h7a3.5 3.5 0 0 1 0 7H7z"/></svg><span class="visually-hidden">Bold</span></button><button class="btn cf-tool" type="button" data-md="_|_|italic text"><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="M10 5h8M6 19h8M15 5 9 19"/></svg><span class="visually-hidden">Italic</span></button><button class="btn cf-tool" type="button" data-md="`|`|code"><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="m9 8-4 4 4 4M15 8l4 4-4 4"/></svg><span class="visually-hidden">Inline code</span></button><button class="btn cf-tool" type="button" data-md="[|](https://)|link text"><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="M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1 1"/><path d="M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1-1"/></svg><span class="visually-hidden">Link</span></button><button class="btn cf-tool" type="button" data-md="> ||quoted text"><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="M7 7h4v4c0 3-2 5-4 6M14 7h4v4c0 3-2 5-4 6"/></svg><span class="visually-hidden">Quote</span></button><button class="btn cf-tool" type="button" data-md="- ||list item"><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="M9 6h11M9 12h11M9 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01"/></svg><span class="visually-hidden">Bulleted list</span></button>
    </div>
    <textarea class="form-control cf-area" id="cf-b-body" name="comment" rows="5" placeholder="What do you think of the new cycle lanes?" required data-required data-max="500" data-msg="Write a comment before posting." aria-describedby="cf-b-hint cf-b-count cf-b-err"></textarea>
  </div>
  <div class="cf-meta mt"><p class="form-text cf-hint" id="cf-b-hint">Markdown works: **bold**, _italic_, `code` and > quote.</p><p class="cf-count" id="cf-b-count" data-count><span data-n>0</span> / 500<span class="visually-hidden"> characters</span></p></div>
  <p class="invalid-feedback cf-err" id="cf-b-err"></p>
  <span class="visually-hidden" aria-live="polite" data-count-live></span>
  <div class="cf-actions end"><button class="btn cf-ghost" type="reset">Cancel</button><button class="btn cf-btn" type="submit" data-idle="Comment" data-sending="Posting…" data-sent="Posted"><span class="spinner-border spinner-border-sm cf-spin" aria-hidden="true"></span><svg class="cf-done" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12 5 5 9-10"/></svg><span data-label>Comment</span></button></div>
  <p class="alert alert-success cf-status" role="status" data-status></p>
</form></div>

<div><span class="lbl">Signed-out prompt</span>
<section class="card cf-gate" aria-labelledby="cf-c-title">
  <span class="cf-gate-i" aria-hidden="true"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a8 8 0 0 1-11.6 7.1L4 20l1-4.6A8 8 0 1 1 21 12Z"/><path d="M8.5 11h7M8.5 14h4"/></svg></span>
  <div class="cf-gate-t"><h2 class="cf-h sm" id="cf-c-title">Join the discussion</h2><p class="cf-intro">Sign in to comment. 142 readers have shared their take on this guide.</p></div>
  <div class="cf-gate-a"><a class="btn cf-btn" href="#">Sign in</a><a class="btn cf-ghost" href="#">Create account</a></div>
</section></div>
CSS
/* Comment forms on Bootstrap: .form-label, .form-control, .form-text, .invalid-feedback, .form-check, .btn, .spinner-border and .alert, restyled through Bootstrap variables.
   Adds a live character count, markdown shortcuts and submit states (idle, posting, posted). */
.cf,.cf-gate{--cf-accent:#4338ca;--cf-accent-dark:#3730a3;--cf-soft:#eef2ff;--cf-ink:#1c1f2e;--cf-muted:#545a6e;--cf-line:#e2e4ee;--cf-field:#8a90a6;--cf-err:#b42318;--cf-ok:#166534;--cf-warn:#92400e;--bs-border-color:var(--cf-field);--bs-body-color:var(--cf-ink);--bs-secondary-color:var(--cf-muted);--bs-form-invalid-color:var(--cf-err);--bs-form-invalid-border-color:var(--cf-err)}
.cf{--bs-card-border-color:var(--cf-line);--bs-card-border-radius:18px;--bs-card-color:var(--cf-ink);padding:28px;font-size:.94rem;line-height:1.5;box-shadow:0 18px 40px -28px rgba(28,31,46,.35)}
.cf :where(h2,p){margin:0}
.cf-h{font-size:1.25rem;font-weight:700;letter-spacing:-.01em;line-height:1.3}
.cf-h.sm{font-size:1.05rem}
.cf-intro{margin-top:4px;color:var(--cf-muted);font-size:.86rem}
.cf-label{font-size:.84rem;font-weight:600}
.cf-req{color:var(--cf-err)}
.cf-opt{font-weight:400;color:var(--cf-muted)}
.cf-hint{font-size:.78rem;color:var(--cf-muted)}
.cf-err{font-size:.8rem;font-weight:600;color:var(--cf-err)}
.cf-err:empty{display:none}
.cf-meta{display:flex;justify-content:space-between;align-items:baseline;gap:12px}
.cf-meta.mt{margin-top:8px}
.cf-count{margin-left:auto;font-size:.78rem;color:var(--cf-muted);font-variant-numeric:tabular-nums;white-space:nowrap}
.cf-count[data-level=near]{color:var(--cf-warn);font-weight:600}
.cf-count[data-level=over]{color:var(--cf-err);font-weight:700}
.cf-actions{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:12px;margin-top:20px}
.cf-actions.end{justify-content:flex-end;gap:10px;margin-top:16px}
.cf-done{display:none}
[data-state=sent] .cf-done{display:inline-block}
.cf-me{display:flex;align-items:center;gap:10px;margin-bottom:14px}
.cf-av{flex:none;display:grid;place-items:center;width:36px;height:36px;border-radius:50%;background:#fde68a;color:#713f12;font-size:.74rem;font-weight:700}
.cf-me-t{font-size:.88rem;color:var(--cf-muted)}
.cf-me-t b{color:var(--cf-ink)}
.cf-link{color:var(--cf-accent);font-weight:600;text-decoration:underline;text-underline-offset:2px}
.cf-link:hover{color:var(--cf-accent-dark)}
.cf-editor{overflow:hidden;border:1.5px solid var(--cf-field);border-radius:12px;background:#fff;transition:border-color .15s,box-shadow .15s}
.cf-editor:focus-within{border-color:var(--cf-accent);box-shadow:0 0 0 3px rgba(67,56,202,.25)}
.cf-editor:has([aria-invalid=true]){border-color:var(--cf-err)}
.cf-tools{display:flex;flex-wrap:wrap;gap:2px;padding:6px;border-bottom:1px solid var(--cf-line);background:#f8f8fb}
.cf-gate{display:flex;flex-wrap:wrap;align-items:center;gap:18px;padding:24px;border:1px dashed var(--cf-field);border-radius:18px;background:#fff;color:var(--cf-ink);font-size:.94rem}
.cf-gate :where(h2,p){margin:0}
.cf-gate-i{flex:none;display:grid;place-items:center;width:48px;height:48px;border-radius:14px;background:var(--cf-soft);color:var(--cf-accent)}
.cf-gate-t{flex:1;min-width:200px}
.cf-gate-a{display:flex;flex-wrap:wrap;gap:10px}
.cf-grid{margin-top:4px}
.cf-f{display:flex;flex-direction:column;gap:6px;min-width:0}
.cf .form-label{margin:0}
.cf .form-text{margin:0}
.cf-input{padding:10px 12px;border-width:1.5px;border-radius:10px;color:var(--cf-ink);font-size:inherit}
.cf-input::placeholder,.cf-area::placeholder{color:#6b7085}
.cf-input:focus{border-color:var(--cf-accent);box-shadow:0 0 0 3px rgba(67,56,202,.25)}
.cf-input[aria-invalid=true]{border-color:var(--cf-err)}
.cf-text{min-height:140px}
.cf .invalid-feedback{display:block;margin:0}
.cf .invalid-feedback:empty{display:none}
.cf-check{display:flex;align-items:center;gap:10px;margin:0;padding:0;font-size:.88rem}
.cf-check .form-check-input{float:none;width:18px;height:18px;margin:0;border:1.5px solid var(--cf-field);cursor:pointer}
.cf-check .form-check-input:checked{background-color:var(--cf-accent);border-color:var(--cf-accent)}
.cf-check .form-check-input:focus{box-shadow:none}
.cf-check .form-check-input:focus-visible{outline:2px solid var(--cf-accent);outline-offset:2px}
.cf-check .form-check-label{cursor:pointer}
.cf-btn,.cf-ghost{--bs-btn-padding-x:20px;--bs-btn-padding-y:0;--bs-btn-font-weight:600;--bs-btn-font-size:.94rem;--bs-btn-border-radius:999px;--bs-btn-focus-box-shadow:none;display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:42px}
.cf-btn{--bs-btn-color:#fff;--bs-btn-bg:var(--cf-accent);--bs-btn-border-color:var(--cf-accent);--bs-btn-hover-color:#fff;--bs-btn-hover-bg:var(--cf-accent-dark);--bs-btn-hover-border-color:var(--cf-accent-dark);--bs-btn-active-color:#fff;--bs-btn-active-bg:var(--cf-accent-dark);--bs-btn-active-border-color:var(--cf-accent-dark)}
.cf-ghost{--bs-btn-color:var(--cf-ink);--bs-btn-border-color:var(--cf-line);--bs-btn-border-width:1.5px;--bs-btn-hover-color:var(--cf-ink);--bs-btn-hover-bg:var(--cf-soft);--bs-btn-hover-border-color:var(--cf-line);--bs-btn-active-bg:var(--cf-soft);--bs-btn-active-border-color:var(--cf-line)}
.cf-btn[aria-disabled=true]{cursor:progress}
[data-state=sent] .cf-btn[type=submit]{--bs-btn-bg:var(--cf-ok);--bs-btn-border-color:var(--cf-ok);--bs-btn-hover-bg:var(--cf-ok);--bs-btn-hover-border-color:var(--cf-ok)}
:is(.cf,.cf-gate) :is(.btn,.cf-link):focus-visible{outline:2px solid var(--cf-accent);outline-offset:2px}
.cf-spin{--bs-spinner-border-width:2px;display:none}
[data-state=sending] .cf-spin{display:inline-block}
.cf-tools{margin:0}
.cf-tool{--bs-btn-padding-x:0;--bs-btn-padding-y:0;--bs-btn-border-width:0;--bs-btn-border-radius:8px;--bs-btn-color:var(--cf-muted);--bs-btn-hover-color:var(--cf-accent-dark);--bs-btn-hover-bg:var(--cf-soft);--bs-btn-active-color:var(--cf-accent-dark);--bs-btn-active-bg:var(--cf-soft);--bs-btn-focus-box-shadow:none;display:grid;place-items:center;width:34px;height:34px}
.cf-tool:focus-visible{outline:2px solid var(--cf-accent);outline-offset:-2px}
.cf-area{min-height:124px;padding:12px 14px;border:0;border-radius:0;color:var(--cf-ink);font-size:inherit}
.cf-area:focus{box-shadow:none}
.cf-status{--bs-alert-padding-x:14px;--bs-alert-padding-y:10px;--bs-alert-margin-bottom:0;--bs-alert-border-radius:12px;--bs-alert-color:var(--cf-ok);--bs-alert-bg:#f0fdf4;--bs-alert-border-color:#bbf7d0;margin-top:16px;font-size:.88rem;font-weight:600}
.cf-status:empty{display:none}
.cf-gate{--bs-card-border-color:var(--cf-field);--bs-card-border-radius:18px;flex-direction:row;border-style:dashed}
@media (max-width:640px){.cf{padding:20px}}
@media (prefers-reduced-motion:reduce){.cf-spin{animation:none}.cf *,.cf-gate *{transition:none}}
JavaScript
/* Comment forms: live character count (announced only when it nears or passes the limit), markdown shortcuts,
   validation that focuses the first problem, and idle / posting / posted states. Frontend only: swap the timeout for your fetch(). */
document.querySelectorAll('[data-comment-form]').forEach(function (form) {
  var area = form.querySelector('textarea'), count = form.querySelector('[data-count]'), live = form.querySelector('[data-count-live]');
  var btn = form.querySelector('[type=submit]'), label = btn.querySelector('[data-label]'), status = form.querySelector('[data-status]');
  var max = +area.dataset.max, level = '';
  var fmt = function (n) { return n.toLocaleString('en-US'); };
  function update() {
    var left = max - area.value.length;
    count.querySelector('[data-n]').textContent = fmt(area.value.length);
    var now = left < 0 ? 'over' : left <= max * 0.1 ? 'near' : '';
    count.dataset.level = now;
    if (now !== level) { level = now; live.textContent = now === 'over' ? fmt(-left) + ' characters over the limit' : now ? fmt(left) + ' characters left' : ''; }
  }
  function problem(f) {
    var v = f.value.trim();
    if (!v || (f.type === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v))) return f.dataset.msg;
    if (f === area && f.value.length > max) return 'Shorten your comment to ' + fmt(max) + ' characters or fewer.';
    return '';
  }
  function showError(f, msg) {
    var err = document.getElementById(f.id + '-err');
    if (err) err.textContent = msg;
    if (msg) f.setAttribute('aria-invalid', 'true'); else f.removeAttribute('aria-invalid');
  }
  function setState(s) {
    form.dataset.state = s; label.textContent = btn.dataset[s || 'idle'];
    if (s) btn.setAttribute('aria-disabled', 'true'); else btn.removeAttribute('aria-disabled');
  }
  form.querySelectorAll('[data-required]').forEach(function (f) {
    f.addEventListener('input', function () { if (f.hasAttribute('aria-invalid')) showError(f, problem(f)); });
  });
  area.addEventListener('input', update);
  form.querySelectorAll('[data-md]').forEach(function (b) {
    b.addEventListener('click', function () {
      var p = b.dataset.md.split('|'), s = area.selectionStart, e = area.selectionEnd, text = area.value.slice(s, e) || p[2];
      var before = p[0]; if (!p[1] && s > 0 && area.value[s - 1] !== '\n') before = '\n' + before;
      area.setRangeText(before + text + p[1], s, e, 'end');
      area.focus(); area.setSelectionRange(s + before.length, s + before.length + text.length); update();
    });
  });
  form.addEventListener('reset', function () {
    setTimeout(function () { update(); form.querySelectorAll('[aria-invalid]').forEach(function (f) { showError(f, ''); }); });
  });
  form.addEventListener('submit', function (e) {
    e.preventDefault();
    if (form.dataset.state) return;
    var first = null;
    form.querySelectorAll('[data-required]').forEach(function (f) { var m = problem(f); showError(f, m); if (m && !first) first = f; });
    if (first) { first.focus(); return; }
    setState('sending'); status.textContent = '';
    setTimeout(function () {
      area.value = ''; update(); setState('sent'); status.textContent = form.dataset.success;
      setTimeout(function () { setState(''); }, 2400);
    }, 1100);
  });
  update();
});

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#545a6e] uppercase">Guest comment form with hints, errors and a live count</span>
<form class="group/f rounded-[18px] border border-cf-line bg-white p-7 text-[.94rem] leading-normal text-cf-ink shadow-[0_18px_40px_-28px_rgba(28,31,46,.35)] max-sm:p-5" data-comment-form data-success="Thanks. Your comment is awaiting moderation and will appear once approved." novalidate aria-labelledby="cf-a-title">
  <h2 class="text-[1.25rem] leading-[1.3] font-bold tracking-[-.01em]" id="cf-a-title">Leave a reply</h2>
  <p class="mt-1 text-[.86rem] text-cf-muted">Your email address will not be published. Fields marked * are required.</p>
  <div class="mt-5 grid grid-cols-3 gap-4 max-[576px]:grid-cols-1">
      <div class="flex min-w-0 flex-col gap-1.5">
        <label class="text-[.84rem] font-semibold" for="cf-a-name">Name <span class="text-cf-err" aria-hidden="true">*</span></label>
        <input class="w-full rounded-[10px] border-[1.5px] border-cf-field bg-white px-3 py-2.5 text-cf-ink transition-[border-color,box-shadow] duration-150 placeholder:text-[#6b7085] focus:border-cf-accent focus:shadow-[0_0_0_3px_rgba(67,56,202,.25)] focus:outline-2 focus:outline-transparent aria-[invalid=true]:border-cf-err motion-reduce:transition-none" id="cf-a-name" name="name" autocomplete="name" required data-required data-msg="Enter your name." aria-describedby="cf-a-name-err">
        <p class="text-[.8rem] font-semibold text-cf-err empty:hidden" id="cf-a-name-err"></p>
      </div>
      <div class="flex min-w-0 flex-col gap-1.5">
        <label class="text-[.84rem] font-semibold" for="cf-a-email">Email <span class="text-cf-err" aria-hidden="true">*</span></label>
        <input class="w-full rounded-[10px] border-[1.5px] border-cf-field bg-white px-3 py-2.5 text-cf-ink transition-[border-color,box-shadow] duration-150 placeholder:text-[#6b7085] focus:border-cf-accent focus:shadow-[0_0_0_3px_rgba(67,56,202,.25)] focus:outline-2 focus:outline-transparent aria-[invalid=true]:border-cf-err motion-reduce:transition-none" id="cf-a-email" type="email" name="email" autocomplete="email" required data-required data-msg="Enter an email address, like [email protected]." aria-describedby="cf-a-email-hint cf-a-email-err">
        <p class="text-[.78rem] text-cf-muted" id="cf-a-email-hint">Never shown publicly</p><p class="text-[.8rem] font-semibold text-cf-err empty:hidden" id="cf-a-email-err"></p>
      </div>
      <div class="flex min-w-0 flex-col gap-1.5">
        <label class="text-[.84rem] font-semibold" for="cf-a-site">Website <span class="font-normal text-cf-muted">(optional)</span></label>
        <input class="w-full rounded-[10px] border-[1.5px] border-cf-field bg-white px-3 py-2.5 text-cf-ink transition-[border-color,box-shadow] duration-150 placeholder:text-[#6b7085] focus:border-cf-accent focus:shadow-[0_0_0_3px_rgba(67,56,202,.25)] focus:outline-2 focus:outline-transparent aria-[invalid=true]:border-cf-err motion-reduce:transition-none" id="cf-a-site" type="url" name="url" autocomplete="url" placeholder="https://">
      </div>
    <div class="col-span-full flex min-w-0 flex-col gap-1.5">
      <label class="text-[.84rem] font-semibold" for="cf-a-body">Comment <span class="text-cf-err" aria-hidden="true">*</span></label>
      <textarea class="w-full rounded-[10px] border-[1.5px] border-cf-field bg-white px-3 py-2.5 text-cf-ink transition-[border-color,box-shadow] duration-150 placeholder:text-[#6b7085] focus:border-cf-accent focus:shadow-[0_0_0_3px_rgba(67,56,202,.25)] focus:outline-2 focus:outline-transparent aria-[invalid=true]:border-cf-err motion-reduce:transition-none min-h-[140px] resize-y" id="cf-a-body" name="comment" rows="6" required data-required data-max="1500" data-msg="Write a comment before posting." aria-describedby="cf-a-body-hint cf-a-body-count cf-a-body-err"></textarea>
      <div class="flex items-baseline justify-between gap-3"><p class="text-[.78rem] text-cf-muted" id="cf-a-body-hint">Be kind and stay on topic. Comments with links are checked before they appear.</p><p class="ml-auto text-[.78rem] whitespace-nowrap text-cf-muted tabular-nums data-[level=near]:font-semibold data-[level=near]:text-cf-warn data-[level=over]:font-bold data-[level=over]:text-cf-err" id="cf-a-body-count" data-count><span data-n>0</span> / 1,500<span class="sr-only"> characters</span></p></div>
      <p class="text-[.8rem] font-semibold text-cf-err empty:hidden" id="cf-a-body-err"></p>
      <span class="sr-only" aria-live="polite" data-count-live></span>
    </div>
  </div>
  <div class="mt-5 flex flex-wrap items-center justify-between gap-3">
    <div class="flex items-center gap-2.5 text-[.88rem]"><input class="size-[18px] cursor-pointer accent-cf-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cf-accent" type="checkbox" id="cf-a-notify" name="notify" checked><label class="cursor-pointer" for="cf-a-notify">Email me when someone replies</label></div>
    <button class="group-data-[state=sent]/f:bg-cf-ok inline-flex min-h-[42px] cursor-pointer items-center justify-center gap-2 rounded-full bg-cf-accent px-5 font-semibold text-white transition-colors duration-150 hover:bg-cf-accent-dark aria-disabled:cursor-progress motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cf-accent" type="submit" data-idle="Post comment" data-sending="Posting…" data-sent="Posted"><span class="hidden size-4 animate-spin rounded-full border-2 border-white/40 border-t-white group-data-[state=sending]/f:inline-block motion-reduce:animate-none" aria-hidden="true"></span><svg class="hidden group-data-[state=sent]/f:inline-block" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12 5 5 9-10"/></svg><span data-label>Post comment</span></button>
  </div>
  <p class="mt-4 rounded-xl border border-[#bbf7d0] bg-[#f0fdf4] px-3.5 py-2.5 text-[.88rem] font-semibold text-cf-ok empty:hidden" role="status" data-status></p>
</form></div>

<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#545a6e] uppercase">Signed-in comment box with a markdown toolbar</span>
<form class="group/f rounded-[18px] border border-cf-line bg-white p-7 text-[.94rem] leading-normal text-cf-ink shadow-[0_18px_40px_-28px_rgba(28,31,46,.35)] max-sm:p-5" data-comment-form data-success="Your comment is live. Thanks for adding to the discussion." novalidate aria-label="Add a comment">
  <div class="mb-3.5 flex items-center gap-2.5"><span class="grid size-9 flex-none place-items-center rounded-full bg-[#fde68a] text-[.74rem] font-bold text-[#713f12]" aria-hidden="true">MK</span><p class="text-[.88rem] text-cf-muted [&_b]:text-cf-ink">Commenting as <b>Mara Kovač</b> · <a class="font-semibold text-cf-accent underline underline-offset-2 hover:text-cf-accent-dark focus-visible:rounded-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cf-accent" href="#">Not you? Sign out</a></p></div>
  <label class="sr-only" for="cf-b-body">Your comment</label>
  <div class="overflow-hidden rounded-xl border-[1.5px] border-cf-field bg-white transition-[border-color,box-shadow] duration-150 focus-within:border-cf-accent focus-within:shadow-[0_0_0_3px_rgba(67,56,202,.25)] has-[[aria-invalid=true]]:border-cf-err motion-reduce:transition-none">
    <div class="flex flex-wrap gap-0.5 border-b border-cf-line bg-[#f8f8fb] p-1.5" role="group" aria-label="Formatting">
      <button class="grid size-[34px] cursor-pointer place-items-center rounded-lg text-cf-muted transition-colors duration-150 hover:bg-cf-soft hover:text-cf-accent-dark motion-reduce:transition-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-cf-accent" type="button" data-md="**|**|bold text"><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="M7 5h6a3.5 3.5 0 0 1 0 7H7zM7 12h7a3.5 3.5 0 0 1 0 7H7z"/></svg><span class="sr-only">Bold</span></button><button class="grid size-[34px] cursor-pointer place-items-center rounded-lg text-cf-muted transition-colors duration-150 hover:bg-cf-soft hover:text-cf-accent-dark motion-reduce:transition-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-cf-accent" type="button" data-md="_|_|italic text"><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="M10 5h8M6 19h8M15 5 9 19"/></svg><span class="sr-only">Italic</span></button><button class="grid size-[34px] cursor-pointer place-items-center rounded-lg text-cf-muted transition-colors duration-150 hover:bg-cf-soft hover:text-cf-accent-dark motion-reduce:transition-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-cf-accent" type="button" data-md="`|`|code"><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="m9 8-4 4 4 4M15 8l4 4-4 4"/></svg><span class="sr-only">Inline code</span></button><button class="grid size-[34px] cursor-pointer place-items-center rounded-lg text-cf-muted transition-colors duration-150 hover:bg-cf-soft hover:text-cf-accent-dark motion-reduce:transition-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-cf-accent" type="button" data-md="[|](https://)|link text"><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="M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1 1"/><path d="M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1-1"/></svg><span class="sr-only">Link</span></button><button class="grid size-[34px] cursor-pointer place-items-center rounded-lg text-cf-muted transition-colors duration-150 hover:bg-cf-soft hover:text-cf-accent-dark motion-reduce:transition-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-cf-accent" type="button" data-md="> ||quoted text"><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="M7 7h4v4c0 3-2 5-4 6M14 7h4v4c0 3-2 5-4 6"/></svg><span class="sr-only">Quote</span></button><button class="grid size-[34px] cursor-pointer place-items-center rounded-lg text-cf-muted transition-colors duration-150 hover:bg-cf-soft hover:text-cf-accent-dark motion-reduce:transition-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-cf-accent" type="button" data-md="- ||list item"><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="M9 6h11M9 12h11M9 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01"/></svg><span class="sr-only">Bulleted list</span></button>
    </div>
    <textarea class="block min-h-[124px] w-full resize-y border-0 bg-white px-3.5 py-3 text-cf-ink placeholder:text-[#6b7085] focus:outline-none" id="cf-b-body" name="comment" rows="5" placeholder="What do you think of the new cycle lanes?" required data-required data-max="500" data-msg="Write a comment before posting." aria-describedby="cf-b-hint cf-b-count cf-b-err"></textarea>
  </div>
  <div class="mt-2 flex items-baseline justify-between gap-3"><p class="text-[.78rem] text-cf-muted" id="cf-b-hint">Markdown works: **bold**, _italic_, `code` and > quote.</p><p class="ml-auto text-[.78rem] whitespace-nowrap text-cf-muted tabular-nums data-[level=near]:font-semibold data-[level=near]:text-cf-warn data-[level=over]:font-bold data-[level=over]:text-cf-err" id="cf-b-count" data-count><span data-n>0</span> / 500<span class="sr-only"> characters</span></p></div>
  <p class="text-[.8rem] font-semibold text-cf-err empty:hidden" id="cf-b-err"></p>
  <span class="sr-only" aria-live="polite" data-count-live></span>
  <div class="mt-4 flex flex-wrap items-center justify-end gap-2.5"><button class="inline-flex min-h-[42px] cursor-pointer items-center justify-center rounded-full border-[1.5px] border-cf-line bg-transparent px-5 font-semibold text-cf-ink transition-colors duration-150 hover:bg-cf-soft motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cf-accent" type="reset">Cancel</button><button class="group-data-[state=sent]/f:bg-cf-ok inline-flex min-h-[42px] cursor-pointer items-center justify-center gap-2 rounded-full bg-cf-accent px-5 font-semibold text-white transition-colors duration-150 hover:bg-cf-accent-dark aria-disabled:cursor-progress motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cf-accent" type="submit" data-idle="Comment" data-sending="Posting…" data-sent="Posted"><span class="hidden size-4 animate-spin rounded-full border-2 border-white/40 border-t-white group-data-[state=sending]/f:inline-block motion-reduce:animate-none" aria-hidden="true"></span><svg class="hidden group-data-[state=sent]/f:inline-block" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12 5 5 9-10"/></svg><span data-label>Comment</span></button></div>
  <p class="mt-4 rounded-xl border border-[#bbf7d0] bg-[#f0fdf4] px-3.5 py-2.5 text-[.88rem] font-semibold text-cf-ok empty:hidden" role="status" data-status></p>
</form></div>

<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-[#545a6e] uppercase">Signed-out prompt</span>
<section class="flex flex-wrap items-center gap-[18px] rounded-[18px] border border-dashed border-cf-field bg-white p-6 text-[.94rem] text-cf-ink" aria-labelledby="cf-c-title">
  <span class="grid size-12 flex-none place-items-center rounded-[14px] bg-cf-soft text-cf-accent" aria-hidden="true"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a8 8 0 0 1-11.6 7.1L4 20l1-4.6A8 8 0 1 1 21 12Z"/><path d="M8.5 11h7M8.5 14h4"/></svg></span>
  <div class="min-w-[200px] flex-1"><h2 class="text-[1.05rem] leading-[1.3] font-bold tracking-[-.01em]" id="cf-c-title">Join the discussion</h2><p class="mt-1 text-[.86rem] text-cf-muted">Sign in to comment. 142 readers have shared their take on this guide.</p></div>
  <div class="flex flex-wrap gap-2.5"><a class="inline-flex min-h-[42px] items-center justify-center rounded-full bg-cf-accent px-5 font-semibold text-white no-underline transition-colors duration-150 hover:bg-cf-accent-dark motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cf-accent" href="#">Sign in</a><a class="inline-flex min-h-[42px] items-center justify-center rounded-full border-[1.5px] border-cf-line px-5 font-semibold text-cf-ink no-underline transition-colors duration-150 hover:bg-cf-soft motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cf-accent" href="#">Create account</a></div>
</section></div>
CSS
/* Palette as theme colours. Form states use group-data-[state=…]/f: variants on the form, the count uses data-[level=…]:,
   errors use aria-[invalid=true]: and empty:hidden, and the editor ring uses focus-within: and has-[…]:. */
@theme {
  --color-cf-accent:#4338ca; --color-cf-accent-dark:#3730a3; --color-cf-soft:#eef2ff; --color-cf-ink:#1c1f2e;
  --color-cf-muted:#545a6e; --color-cf-line:#e2e4ee; --color-cf-field:#8a90a6; --color-cf-err:#b42318;
  --color-cf-ok:#166534; --color-cf-warn:#92400e;
}
JavaScript
/* Comment forms: live character count (announced only when it nears or passes the limit), markdown shortcuts,
   validation that focuses the first problem, and idle / posting / posted states. Frontend only: swap the timeout for your fetch(). */
document.querySelectorAll('[data-comment-form]').forEach(function (form) {
  var area = form.querySelector('textarea'), count = form.querySelector('[data-count]'), live = form.querySelector('[data-count-live]');
  var btn = form.querySelector('[type=submit]'), label = btn.querySelector('[data-label]'), status = form.querySelector('[data-status]');
  var max = +area.dataset.max, level = '';
  var fmt = function (n) { return n.toLocaleString('en-US'); };
  function update() {
    var left = max - area.value.length;
    count.querySelector('[data-n]').textContent = fmt(area.value.length);
    var now = left < 0 ? 'over' : left <= max * 0.1 ? 'near' : '';
    count.dataset.level = now;
    if (now !== level) { level = now; live.textContent = now === 'over' ? fmt(-left) + ' characters over the limit' : now ? fmt(left) + ' characters left' : ''; }
  }
  function problem(f) {
    var v = f.value.trim();
    if (!v || (f.type === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v))) return f.dataset.msg;
    if (f === area && f.value.length > max) return 'Shorten your comment to ' + fmt(max) + ' characters or fewer.';
    return '';
  }
  function showError(f, msg) {
    var err = document.getElementById(f.id + '-err');
    if (err) err.textContent = msg;
    if (msg) f.setAttribute('aria-invalid', 'true'); else f.removeAttribute('aria-invalid');
  }
  function setState(s) {
    form.dataset.state = s; label.textContent = btn.dataset[s || 'idle'];
    if (s) btn.setAttribute('aria-disabled', 'true'); else btn.removeAttribute('aria-disabled');
  }
  form.querySelectorAll('[data-required]').forEach(function (f) {
    f.addEventListener('input', function () { if (f.hasAttribute('aria-invalid')) showError(f, problem(f)); });
  });
  area.addEventListener('input', update);
  form.querySelectorAll('[data-md]').forEach(function (b) {
    b.addEventListener('click', function () {
      var p = b.dataset.md.split('|'), s = area.selectionStart, e = area.selectionEnd, text = area.value.slice(s, e) || p[2];
      var before = p[0]; if (!p[1] && s > 0 && area.value[s - 1] !== '\n') before = '\n' + before;
      area.setRangeText(before + text + p[1], s, e, 'end');
      area.focus(); area.setSelectionRange(s + before.length, s + before.length + text.length); update();
    });
  });
  form.addEventListener('reset', function () {
    setTimeout(function () { update(); form.querySelectorAll('[aria-invalid]').forEach(function (f) { showError(f, ''); }); });
  });
  form.addEventListener('submit', function (e) {
    e.preventDefault();
    if (form.dataset.state) return;
    var first = null;
    form.querySelectorAll('[data-required]').forEach(function (f) { var m = problem(f); showError(f, m); if (m && !first) first = f; });
    if (first) { first.focus(); return; }
    setState('sending'); status.textContent = '';
    setTimeout(function () {
      area.value = ''; update(); setState('sent'); status.textContent = form.dataset.success;
      setTimeout(function () { setState(''); }, 2400);
    }, 1100);
  });
  update();
});

About this set

Three comment forms for the moment a reader decides to say something. The guest comment form is the classic blog layout: name, email with a Never shown publicly hint, an optional website, a comment box with a live character count, a notify-me checkbox and a Post comment button. The signed-in comment box shows who is commenting and adds a formatting toolbar that wraps the selected text in markdown for bold, italic, code, links, quotes and lists. The signed-out prompt replaces the form with Sign in and Create account links. Use the guest form on open blogs, the toolbar box on communities with accounts, and the prompt where only members may comment. The count turns amber near the limit and red past it, and a hidden live region announces it only when it changes level, not on every key. On submit the script checks the required fields, writes an error under each problem field, marks it aria-invalid and focuses the first one; a valid form moves the button through Posting and Posted states and shows a confirmation. It is frontend only: replace the timeout with your own request.

What’s included

  • Live character count that warns near the limit and blocks posting past it
  • Markdown toolbar that wraps the selection or inserts placeholder text
  • Inline errors linked with aria-describedby and aria-invalid
  • Idle, Posting and Posted button states with a spinner and a success message
  • A signed-out prompt with Sign in and Create account links

Accessibility

Every field has a visible label, hints and errors are linked with aria-describedby, and invalid fields get aria-invalid and focus on submit. The character count is read with the field and announced through a polite live region only when it nears or passes the limit. Toolbar buttons have text names, the busy submit button uses aria-disabled instead of disappearing from the tab order, and every control shows a focus outline or ring; the spinner stops under prefers-reduced-motion.

Customise it

Change --cf-accent, --cf-field, --cf-err and --cf-ok on .cf for colour and data-max on the textarea for the limit. In Bootstrap the same variables restyle .form-control, .btn and .alert; in Tailwind edit the --color-cf-* theme colours.