Accessibility pattern · Form inputs

Text area with character count

The count is always on screen and part of the field's description, so it is read when focus arrives, but it is not a live region. A separate polite message speaks only when the count crosses 50, 20 or 0 left, or goes over, so typing is never drowned out.

WCAG criteria
6
Keyboard rules
3
Checked with
axe, keyboard and the inspector

Live demo

Try it

Use it with a mouse, a keyboard or a screen reader. The inspector beside it shows what the browser tells assistive technology as you go: focus, state changes and announcements.

Message your host

Meera Kulkarni · Hillside homestay, Coorg

Say when you expect to arrive and anything you need.

200 characters left

Ctrl + Enter also sends

The demo works without JavaScript only as far as its HTML does; the inspector needs JavaScript.

Keyboard

Keys it answers to

Every action works without a pointer.

KeyWhat it does
TabMoves into the message; its hint and the count are read with it. The next Tab goes to Send message.
EnterIn the message, starts a new line. On Send message, sends it, unless it is empty or over the limit.
CtrlEnterSends from inside the message, with the same checks as the button.

Screen readers

What it announces

Written from the roles, names and states in the markup.

WhenExpected announcement
Focus reaches the messageYour message, edit text, multi line. Say when you expect to arrive and anything you need. 200 characters left
Typing reaches 50 characters left50 characters left
Typing reaches 20 left, then the limit20 characters left … 0 characters left
The text goes over the limit4 characters too many
Ctrl+Enter is pressed while over the limitYour message is too long. Shorten it, then send.
The message is sentMessage sent to Meera

These are expected announcements, not recordings. Wording and order differ between screen readers and browsers.

Code

Copy the code

The exact files this demo runs on. The styles are served with a prefix that keeps this site's own styles out of the demo; what you copy is the original.

The markup

<form class="ap-textarea-counter" data-ap-textarea-counter data-max="200" novalidate aria-labelledby="tc-form-name">
  <div class="ap-textarea-counter__top">
    <span class="ap-textarea-counter__avatar" aria-hidden="true" translate="no">MK</span>
    <div class="ap-textarea-counter__who">
      <h3 class="ap-textarea-counter__name" id="tc-form-name">Message your host</h3>
      <p class="ap-textarea-counter__sub">Meera Kulkarni · Hillside homestay, Coorg</p>
    </div>
  </div>

  <div class="ap-field ap-textarea-counter__body">
    <label class="ap-label" for="tc-message">Your message</label>
    <p class="ap-hint" id="tc-hint">Say when you expect to arrive and anything you need.</p>
    <textarea class="ap-input ap-textarea-counter__input" id="tc-message" name="message" rows="5" aria-describedby="tc-hint tc-count" aria-keyshortcuts="Control+Enter"></textarea>
    <div class="ap-textarea-counter__meta">
      <p class="ap-error ap-textarea-counter__error" id="tc-error" hidden><svg class="ap-textarea-counter__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.5h.01"/></svg><span class="ap-textarea-counter__msg"></span></p>
      <p class="ap-textarea-counter__count" id="tc-count" data-state="ok">
        <svg class="ap-textarea-counter__ring" viewBox="0 0 20 20" aria-hidden="true" focusable="false"><circle class="ap-textarea-counter__track" cx="10" cy="10" r="8"/><circle class="ap-textarea-counter__fill" cx="10" cy="10" r="8" pathLength="100"/></svg>
        <svg class="ap-textarea-counter__icon ap-textarea-counter__over" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><path d="M12 16.5h.01"/></svg>
        <span class="ap-textarea-counter__n" data-ap-left>200</span> <span data-ap-words>characters left</span>
      </p>
    </div>
    <p class="ap-textarea-counter__vh" aria-live="polite" data-ap-live></p>
  </div>

  <div class="ap-textarea-counter__foot">
    <p class="ap-textarea-counter__status" role="status"></p>
    <p class="ap-textarea-counter__tip"><kbd>Ctrl</kbd> + <kbd>Enter</kbd> <span>also sends</span></p>
    <button type="submit" class="ap-btn ap-btn--primary">
      <svg class="ap-btn__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 12 20 4l-4 16-4-7-8-1Z"/><path d="m12 13 8-9"/></svg>
      Send message
    </button>
  </div>
</form>

WCAG 2.2

What it meets

The success criteria this pattern takes care of, and how.

  • 1.3.1 Info and Relationships Level A

    The hint and the count are joined to the text area through aria-describedby, so the limit is part of the field, not just a number near it.

  • 1.4.1 Use of Color Level A

    Near the limit the count changes color, and over it the count gains an icon and says "too many" in words, so color is never the only signal.

  • 3.3.1 Error Identification Level A

    Sending an empty or too-long message marks the field aria-invalid and shows the problem in text beside the count.

  • 3.3.2 Labels or Instructions Level A

    The count is on screen before typing starts, so the limit is known up front, not discovered after.

  • 3.3.3 Error Suggestion Level AA

    The over-limit message says what to do: shorten it, then send. The count says by how much.

  • 4.1.3 Status Messages Level AA

    Threshold counts and the sent message reach screen readers through polite live regions, without moving focus.

Usage

When to use it

Use it

  • Text with a hard limit the server enforces: a message, a bio, a review, a delivery note.
  • Limits people may hit in normal use, where knowing the room left changes what they write.

Use something else

  • Limits nobody reaches, like 5,000 characters for a name: just enforce it on the server.
  • Short single-line values: a text field with the limit in its hint is enough.

Common failures

How it usually goes wrong

  • maxlength that cuts text off

    maxlength silently drops whatever is typed or pasted past the limit, often mid-word and with no message. Here nothing is ever cut: the count goes over, says so, and sending waits.

  • A live region on the count itself

    Announcing the count on every keystroke talks over the typing and makes the field unusable with a screen reader. Here only the thresholds are spoken.

  • A count that appears only near the limit

    If the number shows up only at 20 left, people plan their message without knowing the limit. It is visible from the start.

  • Over the limit shown only in red

    A count that just turns red is missed by people who cannot see the difference. Here it also says "too many" and gains an icon.

  • Counting differently from the server

    A counter that counts emoji as two while the server counts one, or the reverse, blocks valid messages or lets bad ones through. Count the way your back end does.

Notes

Building it

  • The announcement waits for a short pause and says the count as it is then, so pasting past two thresholds at once gives one message, not two.
  • Thresholds are bands: the message speaks when the count enters a new band (50, 20, 0 or over) in either direction, and stays quiet inside one.
  • The count is part of aria-describedby, so anyone can hear the current number by moving focus back to the field.
  • The script counts code points, so an emoji counts once. If your server counts UTF-16 units or bytes, change the count to match.
  • Once an error is shown, it clears as soon as the message is back within the limit; it does not wait for the next send.

Sources: GOV.UK Design System: Character count · WAI-ARIA 1.2: aria-live

Checked with axe in light and dark themes, at desktop and phone widths, and by keyboard. Not yet tested with every screen reader and browser pair. Report a correction