Component Library

← Back to gallery

Textarea with counter

Category: Forms · Tags: textarea, character-count, limit, live-region, bio

Shown on your public profile. Up to 160 characters.

160 characters left

How to use it

Use for free text with a real length limit, such as a profile bio or SMS message. Prefer a soft limit like this one over maxlength, which silently drops pasted text; still enforce the limit on the server. Leave the counter out when the limit is generous enough that nobody reaches it. Change the data-limit value, the ids used by aria-describedby, and the wording of the hint.

Accessibility

A native <textarea> with a <label for>; aria-describedby points at both the hint and the visible counter, so the current count is read when the field is focused. The visible counter is not a live region (it would interrupt on every keystroke); a separate visually hidden role="status" speaks only at 20 and 10 characters left, at the limit and when the text goes over it. Going over is allowed so pasted text is not cut silently: the counter switches to "N characters too many" in bold red text and the field gets aria-invalid="true" and a thicker border, so the state is not shown by colour alone. Text is #0f172a on #ffffff (17.85:1), the counter #475569 on #ffffff (7.58:1) and over-limit text #b91c1c on #ffffff (6.47:1).

Source (copy-ready)

<div class="dcl-textcount" data-limit="160">
  <label class="dcl-textcount__label" for="dcl-textcount-bio">Short bio</label>
  <p class="dcl-textcount__hint" id="dcl-textcount-hint">Shown on your public profile. Up to 160 characters.</p>
  <textarea class="dcl-textcount__field" id="dcl-textcount-bio" name="bio" rows="4" aria-describedby="dcl-textcount-hint dcl-textcount-count"></textarea>
  <p class="dcl-textcount__count" id="dcl-textcount-count">160 characters left</p>
  <p class="dcl-textcount__live" role="status"></p>
</div>
<style>
.dcl-textcount { display: flex; flex-direction: column; gap: 0.35rem; max-width: 26rem; font: inherit; color: #0f172a; background-color: #ffffff; }
.dcl-textcount__label { font-weight: 600; color: #0f172a; background-color: #ffffff; }
.dcl-textcount__hint { margin: 0; font-size: 0.9rem; color: #475569; background-color: #ffffff; }
.dcl-textcount__field { width: 100%; max-width: 100%; box-sizing: border-box; min-height: 6rem; padding: 0.55rem 0.7rem; border: 1px solid #64748b; border-radius: 0.375rem; resize: vertical; font: inherit; color: #0f172a; background-color: #ffffff; }
.dcl-textcount__field[aria-invalid="true"] { border: 2px solid #b91c1c; }
.dcl-textcount__field:focus-visible { outline: 2px solid #1d4ed8; outline-offset: 1px; }
.dcl-textcount__count { margin: 0; font-size: 0.9rem; color: #475569; background-color: #ffffff; }
.dcl-textcount__count.dcl-textcount__count--over { font-weight: 700; color: #b91c1c; background-color: #ffffff; }
.dcl-textcount__live { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; color: #0f172a; background-color: #ffffff; }
</style>
<script>
(function () {
  document.querySelectorAll(".dcl-textcount").forEach(function (root) {
    if (root.dataset.dclBound) { return; }
    root.dataset.dclBound = "true";
    var field = root.querySelector(".dcl-textcount__field");
    var count = root.querySelector(".dcl-textcount__count");
    var live = root.querySelector(".dcl-textcount__live");
    var limit = parseInt(root.getAttribute("data-limit"), 10) || 160;
    var lastSpoken = null;
    if (!field || !count) { return; }
    function plural(n) { return n === 1 ? " character" : " characters"; }
    function update() {
      var left = limit - field.value.length;
      var over = left < 0;
      var message = over ? (-left) + plural(-left) + " too many" : left + plural(left) + " left";
      count.textContent = message;
      count.classList.toggle("dcl-textcount__count--over", over);
      if (over) { field.setAttribute("aria-invalid", "true"); } else { field.removeAttribute("aria-invalid"); }
      var band = over ? "over" : left <= 0 ? "0" : left <= 10 ? "10" : left <= 20 ? "20" : null;
      if (live && band !== lastSpoken) {
        live.textContent = band ? message : "";
        lastSpoken = band;
      }
    }
    field.addEventListener("input", update);
    update();
  });
})();
</script>

Was this useful? Send feedback.