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.