Skip to Content
Component ExamplesCharacter Counter

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:

Key requirements:

  • Provide a static character limit via aria-describedby so screen readers announce it on focus
  • Use aria-live="polite" with aria-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

Inaccessible
<!-- 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>
Live Preview
0/280
Accessible
<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>
Live Preview
Maximum 280 characters
280 characters remaining

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-live region
  • When the user hits the limit, there is no aria-invalid feedback — 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:

EventInaccessible versionAccessible 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 charactersNothing”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:

MechanismWhen it speaksWhat 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

TechniquePurposeWhere to apply
aria-describedbyAnnounce character limit on focusOn the textarea, pointing to static hint text
aria-live="polite"Announce remaining count at thresholdsOn a visually hidden span separate from the visual counter
aria-atomic="true"Ensure the entire live region text is readOn the live region span
Threshold announcementsPrevent flooding the speech queueIn the oninput handler, check against specific remaining values
aria-invalid="true"Convey that the limit has been reachedOn the textarea when remaining reaches 0
Clear-then-set patternForce re-announcement of repeated valuestextContent = ''; setTimeout(fn, 100)

Resources