# Character counters that stay silent or speak on every key

Source: https://easeweb.dev/learn/character-count
Topics: Live updates > Character counters announce when the limit is near (WCAG 4.1.3)
The fix: Say the limit in a hint tied to the field, hide the visible counter from screen readers, and write the count into a visually hidden role="status" region once typing has paused for about a second, from when the limit is near. Don't put aria-live on the counter that changes with every key.
Test with: screen reader, keyboard
References: https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html https://www.w3.org/WAI/WCAG22/Techniques/failures/F103 https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA22 https://design-system.service.gov.uk/components/character-count/ https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-live

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A small homeware shop lets people add a gift message at checkout. The box takes up to 100 characters, and under it, on the right, a line counts down as people type: "100 characters left", then 99, then 98. A sighted customer writes "Happy birthday! May this tea set make your Sunday mornings slow and warm. Love, Jo xx", glances down, sees "15 characters left", and knows there is room for one more short line.

A screen reader user writing the same message hears their own letters, and nothing else. They don't know the box has a limit, or how close they are to it. When they reach 100 characters, the field stops taking letters and nothing says why: the end of the sentence they were typing is simply not there. To find out how much room is left, they have to stop, leave the field and read the page around it.

On a second version of the shop, the counter is an assertive live region. Now every key brings a number: "14 characters left", "13 characters left", "12 characters left". Depending on the screen reader, each count may be read after the letter, or started and cut short by the next key. Either way, a person typing a sentence hears a count after nearly every letter, and loses track of the words they are writing.

## Why it fails

WCAG defines a status message as a change in content that is not a change of context and that tells people about the results of an action, a waiting state, the progress of a process or errors. "15 characters left" reports the result of each key typed, and it changes without taking focus. Success Criterion 4.1.3 asks that such messages can be determined through role or properties, so that assistive technology can present them without moving focus.

In the first Before, the count is a plain `p`. Its text changes on screen, but nothing tells assistive technology to read the change, which is the failure W3C describes in F103. The second Before gives the counter `aria-live="assertive"`. That does tell the screen reader about each change, as a message to present at once, and the script writes a new count on every key. Understanding 4.1.3 lists `role="alert"` or `aria-live="assertive"` on content that is not important and time-sensitive among its failures, and says status messages should reach people without unnecessarily interrupting their work. A count of characters left is routine: it can wait until the person stops typing.

## Who is affected

Limits are common in gift messages, reviews, profile bios and support forms, and the counter is often the only place on the page that states the limit at all.

## What automation and AI miss

A scan loads the page and finds "100 characters left" in the markup. It never types in the field, so it never sees the line change, and nothing in the HTML says the line is a count of what is typed. EaseWeb's audit does type: it fills a field that has a counter to 10 characters short of its limit, types three letters, and watches the live regions. A count rewritten in an assertive region on each of the three keys is a finding. A count rewritten in a polite region on each key, or a counter that changed with nothing written to any live region, while the keys went in or in the pause after, is a lead for a person to check with a screen reader. AI assistants asked to make a counter accessible often add `aria-live` to the visible counter, `polite` or `assertive`. That makes it heard, on every key, which is the second Before. Only typing into the field with a screen reader running shows what is said, and when.

## Before: a counter that changes on screen only

```html
<label for="gift">Gift message</label>
<textarea id="gift" name="gift" maxlength="100"></textarea>
<p id="gift-count" class="count">100 characters left</p>
```

```js
function countText(left) {
  return left === 1 ? '1 character left' : `${left} characters left`;
}

gift.addEventListener('input', () => {
  count.textContent = countText(100 - gift.value.length);
});
```

The count is right, and it sits right under the box. One thing is wrong:

- The `p` is not a live region, so the count changes on screen and is never announced, and when the box is full, nothing says why it stopped taking letters.

## Before: a counter in an assertive live region

```html
<label for="gift">Gift message</label>
<textarea id="gift" name="gift" maxlength="100"></textarea>
<p id="gift-count" class="count" aria-live="assertive">100 characters left</p>
```

```js
function countText(left) {
  return left === 1 ? '1 character left' : `${left} characters left`;
}

gift.addEventListener('input', () => {
  count.textContent = countText(100 - gift.value.length);
});
```

The count is now announced, but in the wrong way:

- `aria-live="assertive"` asks for each new count to be presented at once, though a count is never urgent.
- The script writes a new count on every key, so there is a message after nearly every letter typed.

## After: the count said once, when typing pauses near the limit

```html
<label for="gift">Gift message</label>
<p id="gift-hint" class="hint">You can enter up to 100 characters.</p>
<textarea id="gift" name="gift" maxlength="100" aria-describedby="gift-hint"></textarea>
<p id="gift-count" class="count" aria-hidden="true">100 characters left</p>
<p id="gift-status" class="visually-hidden" role="status"></p>
```

```js
function countText(left) {
  return left === 1 ? '1 character left' : `${left} characters left`;
}

// Said once typing has paused for a second, from 20 characters left.
let pause;
gift.addEventListener('input', () => {
  const left = 100 - gift.value.length;
  count.textContent = countText(left);
  clearTimeout(pause);
  pause = setTimeout(() => {
    status.textContent = left <= 20 ? countText(left) : '';
  }, 1000);
});
```

1. Put the limit in a hint above the box and tie it to the field with `aria-describedby`. The screen reader reads "You can enter up to 100 characters" when the person reaches the field, before they type a word.
2. Take `aria-live` off the visible counter and give it `aria-hidden="true"`. It still changes on every key for people who see it, but it is no longer read on each key, or a second time when someone reads the page.
3. Add one empty `p` with `role="status"`, visually hidden, that is on the page from the start, so writing into it is a change the screen reader announces, once it has finished what it is saying.
4. Write the count into it once typing has paused for a second, and only from 20 characters left. A person typing hears nothing about the count until they stop near the limit, then hears "12 characters left" once. Further from the limit, the region is emptied, so the next count near it is a change again.

This fixes both Befores and passes 4.1.3. The count is a status message given to the screen reader through its role, politely, once per pause, instead of never or after every key.

## Implementation decisions

**How long to wait.** The design systems that do this wait about a second. GOV.UK's character count updates its screen reader message once no key has been typed for half a second, checking every second, and the U.S. Web Design System's waits 1.2 seconds after the last key. Much shorter, and a slow typist hears a count between words; much longer, and the count arrives after they have moved on.

**From when to speak.** A count read from the first letter is noise for anyone writing a short message. GOV.UK's component has a threshold option that shows and announces its count only once a set share of the limit is used, such as 75%; this example speaks from 20 characters left. Because the hint states the limit when the field is reached, nobody needs the count before it is near.

**A polite counter on every key.** The more common form of the second Before is the same counter with `aria-live="polite"`, which some component libraries render while the field has focus. Each change is announced, so it meets the words of 4.1.3, but it still sends a message on every key, and in some screen readers a count is read after nearly every letter. The fix is the same: one hidden region, written after a pause.

**Over the limit instead of cut off.** With `maxlength`, the browser stops taking letters at the limit and cuts off anything pasted beyond it, without a word. GOV.UK's component leaves `maxlength` off and lets people go over, so they can type or paste their whole answer and then edit it down, with the count saying how many characters too many. If you do that, the form still has to stop and explain on submit, and errors shown while typing are covered in [inline form errors read out while typing, or not at all](/learn/inline-validation).

**Count what the limit counts.** `maxlength` and `value.length` count UTF-16 code units, so an emoji can count as two characters. A server that limits bytes counts most Japanese characters as three, in UTF-8. Count in the same unit as whatever enforces the limit, and say that unit in the hint.

**Speech input.** Some speech recognition software changes a field's value without the events a script listens for. GOV.UK's component also checks the value once a second while the field has focus, so a dictated sentence still updates the count.

**Not fixes.** Pointing the field's `aria-describedby` at the changing counter gets it read when the person reaches the field, but not as they type, so the count they hear is the one from before they started. `role="alert"` has the same problem as the second Before. Moving focus to the counter takes the caret out of the box mid-sentence.

## Verify the fix

1. Inspect the DOM before typing: the visible counter has `aria-hidden="true"` and no `aria-live`; an empty element with `role="status"` is present and not hidden with `display: none`; and the field's `aria-describedby` points at the text that states the limit.
2. With a screen reader on, move to the field: its label is read, then "You can enter up to 100 characters".
3. Type a sentence while far from the limit, pausing between words: nothing about the count is read.
4. Type until fewer than 20 characters are left, at your usual speed, then stop: no count is read between the letters, the number left is read once after you stop, and at the limit "0 characters left" is read once.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the field component, its counter or the limit changes.

## Limits

Live regions behave differently across screen readers and browsers, and some drop a polite message when people start typing again before it is read. This pattern covers one field with a fixed limit in characters; a limit in words works the same way, with words counted instead. Other status messages, such as a saved draft, are covered in [status messages that are shown but never announced](/learn/status-messages). Test your real field, with its real limit, hint and error messages, with the screen readers your users use.

## What screen readers do (tested by hand by EaseWeb)

`role="status"`: A polite live region: its changes wait until the screen reader has finished what it is saying.

| Screen reader | Browser and system | Down Arrow | Say all |
| --- | --- | --- | --- |
| JAWS | Chrome, Windows 11 | Not tested | Not tested |
| NVDA | Chrome, Windows 11 | Not tested | Not tested |
| JAWS | Edge, Windows 11 | Not tested | Not tested |
| NVDA | Firefox, Windows 11 | Not tested | Not tested |
| NVDA | Edge, Windows 11 | Not tested | Not tested |
| JAWS | Firefox, Windows 11 | Not tested | Not tested |
| VoiceOver | Safari, macOS | Not tested | Not tested |
| VoiceOver | Safari, iOS | Not tested | Not tested |
| TalkBack | Chrome, Android | Not tested | Not tested |

`aria-live="assertive"`: Asks for each change to the region to be presented at once.

| Screen reader | Browser and system | Down Arrow | Say all | Typing |
| --- | --- | --- | --- | --- |
| JAWS | Chrome, Windows 11 | Read once the chunk being read ends (2026-10) | Not tested | Not tested |
| NVDA | Chrome, Windows 11 | Read once the chunk being read ends (2026-10) | Not tested | Not tested |
| JAWS | Edge, Windows 11 | Not tested | Not tested | Not tested |
| NVDA | Firefox, Windows 11 | Not tested | Not tested | Not tested |
| NVDA | Edge, Windows 11 | Not tested | Not tested | Not tested |
| JAWS | Firefox, Windows 11 | Not tested | Not tested | Not tested |
| VoiceOver | Safari, macOS | Not tested | Not tested | Not tested |
| VoiceOver | Safari, iOS | Not tested | Not tested | Not tested |
| TalkBack | Chrome, Android | Not tested | Not tested | Not tested |

`aria-describedby`: Gives an element a description, such as a hint or an error, from text elsewhere on the page.

| Screen reader | Browser and system | Tab to it | Down Arrow |
| --- | --- | --- | --- |
| JAWS | Chrome, Windows 11 | Not tested | Not tested |
| NVDA | Chrome, Windows 11 | Not tested | Not tested |
| JAWS | Edge, Windows 11 | Not tested | Not tested |
| NVDA | Firefox, Windows 11 | Not tested | Not tested |
| NVDA | Edge, Windows 11 | Not tested | Not tested |
| JAWS | Firefox, Windows 11 | Not tested | Not tested |
| VoiceOver | Safari, macOS | Not tested | Not tested |
| VoiceOver | Safari, iOS | Not tested | Not tested |
| TalkBack | Chrome, Android | Not tested | Not tested |

https://easeweb.dev/learn/screen-reader-support

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
