Build a Real-Time Character Counter with Vanilla JavaScript and ARIA Live Regions

Character counters on text inputs and textareas improve form UX by letting users know how much of an allowed limit they have used, preventing truncation errors at the server. A robust implementation requires three components: a live DOM counter element that updates on every input event, an ARIA live region so screen-reader users receive the count without moving focus, and form-level submit prevention when the limit is exceeded. The input event is the correct choice over keyup because it fires on paste, drag-and-drop, speech-to-text, and programmatic value changes — keyup misses all non-keyboard input methods. ARIA aria-live="polite" announces counter changes after the user pauses, avoiding constant interruptions while typing; aria-live="assertive" interrupts immediately and should be reserved for error states such as the limit being reached. The aria-atomic="true" attribute ensures the entire counter text is re-read rather than only the changed portion, giving context such as “45 of 160 characters used” instead of just “45”. Connecting the counter element to the input via aria-describedby provides an additional context link for screen readers when focus is on the field. The counter should display remaining characters (not used) as a default because users care more about how much space they have left than how much they have consumed — switch to used/total for strict compliance contexts. CSS color coding — green for comfortable, amber for approaching the limit, red when at or over — provides an immediate visual signal without relying on text alone. The maxlength HTML attribute hard-truncates input at the browser level, but for soft limits (warn but allow submit) it should be removed and the limit enforced only via JavaScript and server-side validation. JavaScript’s String.length counts UTF-16 code units, meaning a single emoji counts as 2 — use Array.from(field.value).length to match PHP’s mb_strlen() character count for multibyte consistency.

Problem: Text inputs with character limits give no feedback until the server rejects the submission, and screen-reader users cannot detect when a limit is reached because standard counter implementations use visual-only indicators.

Solution: Attach an aria-live counter element after every field that has a data-max-length attribute, update the counter on every input event with remaining characters, change the counter color through CSS classes, and disable the submit button when any field exceeds its limit.

(function () {
    'use strict';

    function createCounter(field, maxLen) {
        const counter   = document.createElement('span');
        counter.className = 'char-counter';
        counter.setAttribute('aria-live', 'polite');
        counter.setAttribute('aria-atomic', 'true');

        const counterId = 'counter-' + (field.id || Math.random().toString(36).slice(2));
        counter.id = counterId;
        field.setAttribute('aria-describedby',
            (field.getAttribute('aria-describedby') || '') + ' ' + counterId);

        field.parentNode.insertBefore(counter, field.nextSibling);
        return counter;
    }

    function updateCounter(counter, used, maxLen) {
        const remaining = maxLen - used;
        counter.textContent = remaining >= 0
            ? remaining + ' of ' + maxLen + ' characters remaining'
            : Math.abs(remaining) + ' characters over the ' + maxLen + ' limit';

        counter.classList.remove('char-counter--ok', 'char-counter--warn', 'char-counter--over');
        if (remaining < 0)                counter.classList.add('char-counter--over');
        else if (remaining <= maxLen * 0.1) counter.classList.add('char-counter--warn');
        else                               counter.classList.add('char-counter--ok');
    }

    function syncSubmitButton(form) {
        const anyOver = Array.from(form.querySelectorAll('[data-max-length]')).some(
            f => Array.from(f.value).length > +f.dataset.maxLength
        );
        const btn = form.querySelector('[type="submit"]');
        if (btn) { btn.disabled = anyOver; btn.setAttribute('aria-disabled', String(anyOver)); }
    }

    function init(root = document) {
        root.querySelectorAll('[data-max-length]').forEach(function (field) {
            const maxLen  = +field.dataset.maxLength;
            if (!maxLen) return;
            const counter = createCounter(field, maxLen);
            updateCounter(counter, Array.from(field.value).length, maxLen);
            field.addEventListener('input', function () {
                updateCounter(counter, Array.from(field.value).length, maxLen);
                const form = field.closest('form');
                if (form) syncSubmitButton(form);
            });
        });
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', () => init());
    } else {
        init();
    }

    window.CharCounter = { init };
}());

.char-counter {
    display: block;
    font-size: 0.8rem;
    margin-top: 4px;
    transition: color 0.2s ease;
}
.char-counter--ok   { color: #2d8a4e; }
.char-counter--warn { color: #b45309; }
.char-counter--over { color: #c0392b; font-weight: 600; }

NOTE: Always validate the character limit server-side with mb_strlen() in PHP — it correctly counts multibyte characters (emoji, accented letters) whereas the JavaScript String.length property counts UTF-16 code units, meaning a single emoji counts as 2 in String.length but 1 in mb_strlen(). The counter uses Array.from(field.value).length to match PHP’s count on the client side.