Overview
Character counters are a common UI pattern for textareas with length limits — tweet composers, bio fields, SMS editors. The accessibility challenge is a tension between two bad extremes: announcing nothing (screen reader users have no idea how many characters remain) and announcing on every keystroke (flooding the speech queue with “279 remaining, 278 remaining, 277 remaining…”). The solution uses two complementary mechanisms: aria-describedby pointing to a static hint that is read once on focus (“Maximum 280 characters”), and a separate aria-live="polite" region that announces the remaining count only at meaningful thresholds.
WCAG Criteria:
- 4.1.3 Status Messages — the remaining character count is a status update and must be announced without moving focus
- 3.3.2 Labels or Instructions — the character limit must be communicated before the user begins typing
Key requirements:
- Provide a static character limit via
aria-describedbyso screen readers announce it on focus - Use
aria-live="polite"witharia-atomic="true"for dynamic remaining-count announcements - Announce remaining characters only at meaningful thresholds (e.g., 200, 100, 50, 20, 10, 0) — not on every keystroke
- When the limit is exceeded or reached, set
aria-invalid="true"on the textarea so screen readers convey the error state - Combine the live region announcement with a visual color change (e.g., red text) for sighted users
- Never remove the visual counter — sighted users rely on it for continuous feedback
Character Counter
Silent Counter vs. Threshold-Announced Counter
<!-- Visual counter updates on every keystroke, no ARIA at all -->
<label for="message">Compose message</label>
<textarea id="message"
rows="4"
maxlength="280"
oninput="
counter.textContent = this.value.length + '/280';
"></textarea>
<!-- Screen reader never knows about the limit or remaining count -->
<div id="counter">0/280</div><label for="message">Compose message</label>
<!-- Static limit — read once when textarea receives focus -->
<span id="char-limit">Maximum 280 characters</span>
<textarea id="message"
rows="4"
maxlength="280"
aria-describedby="char-limit"
oninput="
var remaining = 280 - this.value.length;
// Update visual counter on every keystroke
visual.textContent = remaining + ' characters remaining';
// Mark invalid when limit reached
if (remaining <= 0) {
this.setAttribute('aria-invalid', 'true');
} else {
this.removeAttribute('aria-invalid');
}
// Announce only at thresholds — not every keystroke
var thresholds = [200, 100, 50, 20, 10, 0];
if (thresholds.indexOf(remaining) !== -1) {
srRegion.textContent = '';
setTimeout(function() {
srRegion.textContent =
remaining + ' characters remaining';
}, 100);
}
"></textarea>
<!-- Visual counter — always visible, updates every keystroke -->
<span id="visual">280 characters remaining</span>
<!-- Live region — announces only at thresholds -->
<span id="srRegion"
aria-live="polite"
aria-atomic="true"
class="sr-only"></span>What’s wrong with the silent counter?
- The “0/280” visual counter has no ARIA connection to the textarea — a screen reader user focusing the textarea hears only “Compose message, edit text” with no mention of a character limit
- The counter updates on every keystroke visually, but screen readers never announce any change because there is no
aria-liveregion - When the user hits the limit, there is no
aria-invalidfeedback — the textarea silently stops accepting input - The “X/280” format tells users how many characters they have typed, but not how many remain — “remaining” is more useful
What the screen reader announces:
| Event | Inaccessible version | Accessible version |
|---|---|---|
| Focus on textarea | ”Compose message, edit text" | "Compose message, edit text, Maximum 280 characters” |
| Typing (at 80 chars) | Nothing | ”200 characters remaining” (at threshold) |
| Typing (at 230 chars) | Nothing | ”50 characters remaining” |
| Typing (at 270 chars) | Nothing | ”10 characters remaining” |
| At 280 characters | Nothing | ”0 characters remaining” + aria-invalid signals error |
Why threshold-based announcements matter:
- Announcing on every keystroke floods the speech queue — the screen reader falls behind, reading “250 remaining, 249 remaining…” while the user continues typing
- Announcing only at thresholds (200, 100, 50, 20, 10, 0) gives periodic awareness without disrupting the typing flow
- The thresholds become more frequent as the limit approaches, matching the user’s increasing need for feedback
- The
textContent = ''; setTimeout(...)pattern clears and re-sets the live region so the same threshold value is re-announced if the user deletes and re-types
Two mechanisms working together:
| Mechanism | When it speaks | What it says |
|---|---|---|
aria-describedby (static) | Once, on focus | ”Maximum 280 characters” |
aria-live="polite" (dynamic) | At thresholds during typing | ”50 characters remaining” |
Key Teaching Points
| Technique | Purpose | Where to apply |
|---|---|---|
aria-describedby | Announce character limit on focus | On the textarea, pointing to static hint text |
aria-live="polite" | Announce remaining count at thresholds | On a visually hidden span separate from the visual counter |
aria-atomic="true" | Ensure the entire live region text is read | On the live region span |
| Threshold announcements | Prevent flooding the speech queue | In the oninput handler, check against specific remaining values |
aria-invalid="true" | Convey that the limit has been reached | On the textarea when remaining reaches 0 |
| Clear-then-set pattern | Force re-announcement of repeated values | textContent = ''; setTimeout(fn, 100) |