A dependency-free TypeScript plugin that adds accessible character counts, warnings, and validation feedback to native <textarea> elements—without wrapping or replacing the control.
Live demo · npm · Latest release
- Keeps the native
<textarea>, label, focus behavior, and keyboard interaction. - Uses the visible count and message as the textarea's current description, with a separate transient live region for meaningful state changes.
- Supports warning thresholds, soft or native hard limits, minimum-length validation timing, and custom messages.
- Has no runtime dependencies and requires no framework or custom element registration.
- A browser with ES module support.
- A
<textarea>with a uniqueid. - A sibling plugin root whose
forattribute references that ID.
The package does not include CSS. Style the documented classes and data-state values to match your interface.
npm install a11y-character-count
pnpm add a11y-character-count
yarn add a11y-character-countimport { initCharacterCounts } from "a11y-character-count";
initCharacterCounts();For one root:
import { createCharacterCount } from "a11y-character-count";
const root = document.querySelector("[data-a11y-character-count]");
if (root instanceof HTMLElement) createCharacterCount(root);The textarea and normal plugin root must be siblings. The plugin reads the <output for> value to find the textarea. During initialization, it neutralizes <output>'s implicit live-region role, links the generated visible counter and message through aria-describedby, and uses a separate managed live region so routine keystrokes are not announced.
<label for="message">Message</label>
<textarea id="message" name="message"></textarea>
<output
class="a11y-character-count"
data-a11y-character-count
for="message"
data-min="10"
data-max="300">
</output>The native for attribute identifies the textarea. Plugin configuration uses conforming custom attributes: data-max, data-min, data-warning-at, data-warning-percent, data-hard-limit, data-show-remaining, data-live, data-validate-min, and the data-message-* customization attributes. The older unprefixed configuration attributes remain supported for backward compatibility. State is exposed through data-state on the plugin root.
createCharacterCount(root, options?)initializes one root and reuses its instance.initCharacterCounts(options?)initializes every[data-a11y-character-count]root.- Instances expose
update(),validate(),getCount(), anddestroy().
JavaScript options take precedence over the corresponding root attributes. Non-negative integer options also accept numeric strings; invalid values fall back to the listed default.
| Option | Type | Default | HTML attribute | Purpose |
|---|---|---|---|---|
for |
string |
root for or data-for, then "" |
for, data-for |
ID of the target <textarea>. |
max |
number | string |
null |
data-max |
Maximum character count. |
min |
number | string |
null |
data-min |
Minimum character count. |
warningAt |
number | string |
null |
data-warning-at |
Exact count at which warning state begins; overrides warningPercent. |
warningPercent |
number | string |
80 |
data-warning-percent |
Percentage of max at which warning state begins. |
hardLimit |
boolean | string |
false |
data-hard-limit |
Adds maxlength when the textarea has none. |
showRemaining |
boolean | string |
false |
data-show-remaining |
Includes the remaining count in visible output. |
live |
"off" | "polite" | "assertive" | string |
"polite" |
data-live |
Live-region politeness for meaningful state transitions. |
validateMin |
"input" | "blur" | "manual" | string |
"input" |
data-validate-min |
Controls when minimum validation becomes active. |
messageTooLong |
string |
generated English message | data-message-too-long |
Message for too-long. |
messageTooShort |
string |
generated English message | data-message-too-short |
Message for too-short. |
messageWarning |
string |
generated English message | data-message-warning |
Message for warning. |
messageEmpty |
string |
"" |
data-message-empty |
Message for empty. |
Message templates may use {length}, {max}, {min}, {remaining}, {overLimitBy}, and {underMinBy}. Boolean HTML attributes accept an empty value or "true"; use "false" to disable them. The legacy unprefixed forms of plugin configuration attributes are also accepted.
| Method | Return value | Notes |
|---|---|---|
update() |
CharacterCountResult | null |
Recomputes output and emits update; returns null without a valid target or after teardown. |
validate() |
{ valid: boolean; state: CharacterCountState } |
Activates minimum validation, updates, and returns the current validity; without a target it returns { valid: true, state: "" }. |
getCount() |
count fields below, excluding state and valid |
Reads the current target value without changing validation timing. |
destroy() |
void |
Idempotently removes listeners and managed output. |
CharacterCountResult has this shape:
| Field | Type | Meaning |
|---|---|---|
value |
string |
Current textarea value. |
length |
number |
JavaScript string length. |
max, min |
number | null |
Normalized configured limits. |
remaining |
number | null |
max - length, or null without max; may be negative. |
overLimitBy, underMinBy |
number |
Distance beyond the applicable limit, otherwise 0. |
state |
CharacterCountState |
Current state listed below. |
valid |
boolean |
false only for too-short or too-long. |
| State | Meaning |
|---|---|
"" |
No connected textarea, or the fallback returned after teardown. |
empty |
Empty and minimum validation is not currently failing. |
valid |
Within configured limits. |
warning |
At or above the warning threshold without exceeding max. |
too-short, too-long |
Revealed minimum failure or maximum failure. |
disabled, readonly |
The textarea has the corresponding native state. |
All events bubble from the root and always include detail.instance.
| Event suffix | Additional detail |
When emitted |
|---|---|---|
init |
none | After initial connection and update. |
update |
all CharacterCountResult fields |
On every successful update. |
valid, invalid |
all CharacterCountResult fields |
When validity changes after the initial result. |
warning |
all CharacterCountResult fields |
On entry into warning. |
limit-exceeded |
all CharacterCountResult fields |
On entry into too-long. |
error |
reason: "missing-for" | "invalid-target" |
When the target ID is absent or does not resolve to a textarea. |
destroy |
none | At the end of teardown. |
Prefix each suffix with a11y-character-count:, for example a11y-character-count:update.
Initialization replaces the root's contents with these elements:
| Class | Purpose |
|---|---|
.a11y-character-count__counter |
Visible current count, and optionally the remaining count; referenced by the textarea through aria-describedby. |
.a11y-character-count__message |
Visible warning or validation message; referenced by the textarea through aria-describedby. |
.a11y-character-count__announcement |
Visually hidden live region whose transition announcement is cleared after delivery. |
Use [data-a11y-character-count][data-state="warning"], [data-state="too-short"], and the other documented state values as styling hooks. Do not hide the root with display: none, because that also hides the described counter, message, and live region from assistive technologies.
destroy() clears the root's children and pending announcement timers, removes the generated root ID, listeners, state, plugin-owned description links, and plugin-added native limits, and restores the root's original role, aria-live, and aria-atomic values plus any application-owned aria-invalid. It does not restore children that existed inside the root before initialization. A new instance may be created on the same root after teardown.
Set validateMin in JavaScript or data-validate-min in HTML. Maximum-length validation remains immediate in every mode.
| Mode | Minimum-length feedback appears |
|---|---|
"input" (default) |
After the first input or blur, preserving the original behavior. |
"blur" |
After the textarea first loses focus, including a blur with no input. |
"manual" |
After the instance's validate() method is called. |
Once minimum validation has started, later edits keep the validity state current. A form reset clears the timing state so a reset empty value does not immediately show an error.
For form-submission integration, manual mode lets the application choose when to reveal feedback:
const counter = createCharacterCount(root, { min: 10, validateMin: "manual" });
form.addEventListener("submit", (event) => {
const result = counter.validate();
if (!result.valid) {
event.preventDefault();
textarea.focus();
}
});The plugin never moves focus itself. If submission is blocked, the application should keep the visible message available through the managed aria-describedby relationship and move focus or provide an error summary so delayed feedback is discoverable.
The plugin preserves the native label and textarea. It references the visible counter and message through aria-describedby without removing existing IDs, sets aria-invalid only for invalid states, and restores managed attributes during teardown. A separate live region briefly exposes concise warning, invalid, recovery, and reset transitions instead of announcing every keystroke; its text is then cleared so stale announcements do not remain in the accessibility tree. data-live accepts off, polite, or assertive; off disables transition announcements but keeps the current count and message associated with the textarea. No custom keyboard interaction is introduced because the textarea keeps its native behavior. Minimum-length errors follow the configured validation timing policy. Missing and invalid targets fail safely.
The implementation is designed to support WCAG requirements for error identification, labels and instructions, programmatic state, and status messages. Conformance still depends on the surrounding form, especially making delayed submission errors discoverable. Test the complete experience with your target browsers and assistive technologies.
- Counts use JavaScript string length, so some emoji and combined Unicode characters may count as more than one character.
- Only
<textarea>targets are supported; text inputs andcontenteditableelements are not. - The plugin observes
input,blur, and formresetevents. If code changestextarea.valuedirectly, callupdate()afterward. - Options are normalized at initialization. Destroy and recreate the instance to apply changed configuration.
hardLimitaddsmaxlengthonly when the textarea does not already have one; an application-providedmaxlengthremains authoritative.- The plugin reports validity through its own state and
aria-invalid. Integrating validation with submission, focus management, or an error summary remains the application's responsibility.
The interactive demo covers warning, invalid, recovery, and reset states. Its source is available in index.html. Build the package with npm run build, then open index.html directly or serve the generated docs/ directory.
import { docs } from "a11y-character-count/docs";Run npm run pages:build. The generated docs/ directory is committed output and must not be edited by hand. Configure Settings → Pages → Deploy from a branch → main → /docs once.
npm install
npm run typecheck
npm test
npm run build
npm run pack:checknpm run build creates both the distributable package in dist/ and the static demo in docs/.
