Skip to content

Repository files navigation

A11y Character Count

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

A native textarea enhanced with a visible character count and warning message

Highlights

  • 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.

Requirements

  • A browser with ES module support.
  • A <textarea> with a unique id.
  • A sibling plugin root whose for attribute references that ID.

The package does not include CSS. Style the documented classes and data-state values to match your interface.

Installation

npm install a11y-character-count
pnpm add a11y-character-count
yarn add a11y-character-count

Usage

import { 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);

HTML structure

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.

API

  • createCharacterCount(root, options?) initializes one root and reuses its instance.
  • initCharacterCounts(options?) initializes every [data-a11y-character-count] root.
  • Instances expose update(), validate(), getCount(), and destroy().

Options and defaults

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.

Instance methods and return values

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.

States

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.

Events

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.

Generated elements and styling hooks

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.

Teardown caveats

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.

Minimum-length validation timing

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.

Accessibility notes

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.

Limitations

  • 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 and contenteditable elements are not.
  • The plugin observes input, blur, and form reset events. If code changes textarea.value directly, call update() afterward.
  • Options are normalized at initialization. Destroy and recreate the instance to apply changed configuration.
  • hardLimit adds maxlength only when the textarea does not already have one; an application-provided maxlength remains 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.

Examples

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.

Docs metadata

import { docs } from "a11y-character-count/docs";

GitHub Pages

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.

Development

npm install
npm run typecheck
npm test
npm run build
npm run pack:check

npm run build creates both the distributable package in dist/ and the static demo in docs/.

License

MIT

About

Add accessible counts, warnings, and validation feedback without wrappinDependency-free TypeScript plugin that adds accessible character counts, warnings, and validation feedback to native textareas.g or replacing the control.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages