Build a Textarea Character Counter: JS, React, Vue Guide

Build a Textarea Character Counter: JS, React, Vue Guide

15 min read
Static Forms Team

You've probably got a textarea in a contact form, onboarding flow, or “project brief” page right now, and the only thing guarding it is maxlength. That works until users hit the limit without warning, paste a long answer, or use a screen reader that gets spammed with announcements on every keystroke.

A good textarea character counter fixes that, but the basic “123/300” demo most tutorials show isn't enough for production. The version you want handles paste events, stays quiet until it's useful, and doesn't turn accessibility into an afterthought.

Why a Good Character Counter Matters

A textarea character counter does two jobs at once. It gives users confidence while typing, and it helps the browser enforce a clear boundary before form submission.

Without a counter, people guess. They write a product description, support request, or short bio, then hit a hard limit they didn't see coming. That usually leads to blind editing, resubmission, and frustration that you could've prevented with a few lines of UI.

Basic counters help, but production counters guide

The naive implementation is simple. Show a number under the field from the first keystroke and update it on every input.

That's fine for internal tools. It's weak for public forms.

A production-ready counter needs to answer a few practical questions:

  • When should it appear? Showing it immediately adds visual noise, especially on simple forms.
  • What should it say? “287/300” and “13 characters left” feel different in practice.
  • How should assistive tech hear it? Constant announcements can be worse than no counter at all.
  • What happens when JavaScript fails? The field still needs a usable limit and fallback message.

Practical rule: Treat the counter as guidance, not decoration. If it doesn't reduce uncertainty, it's just another moving part under the field.

What good looks like in real forms

For fields like “Project description” or “Tell us about your issue,” the best pattern is usually progressive disclosure. Keep the interface calm, then surface more feedback as the user approaches the limit.

That means:

  1. Use the native maxlength attribute for the hard stop.
  2. Update on input, not keyboard-only events.
  3. Delay visibility until the user is close enough to care.
  4. Announce meaningful changes to assistive tech, not every single character.

That combination turns a basic script into a professional textarea character counter. It's a small component, but it says a lot about how the rest of your frontend is built.

Building the Core Counter with Vanilla JS

A good counter starts with boring, dependable HTML. That matters more than the framework choice.

A person coding a character counter application using HTML and JavaScript on a modern laptop screen.

I usually build this in plain JavaScript first, even on React or Vue projects. It forces the behavior into the open: where the limit lives, what updates on input, and what still works if hydration is late or fails. The framework version gets easier once those decisions are settled.

The core state is small: read maxlength from the DOM, count textarea.value.length, and update the UI from those two values. The progress bar is just a percentage of the current length against the limit, which matches the basic approach in this character counter walkthrough.

HTML markup

Here's a complete form fragment you can paste into a static site:

HTML
<form action="https://example.com/api/contact" method="POST" novalidate>
  <label for="project-description">Project description</label>
  <textarea
    id="project-description"
    name="message"
    rows="6"
    maxlength="300"
    aria-describedby="project-description-help project-description-status"
    placeholder="Describe your project, timeline, and goals"></textarea>

  <p id="project-description-help">Keep it concise so the team can review it quickly.</p>

  <div class="char-counter" data-counter>
    <div class="char-counter__bar">
      <div class="char-counter__progress" data-progress></div>
    </div>
    <p id="project-description-status" data-remaining aria-live="off">
      Character limit: 300
    </p>
  </div>

  <button type="submit">Send request</button>
</form>

Two implementation details are doing real work here. maxlength stays in the markup, so the browser enforces the cap without JavaScript. aria-describedby ties the textarea to both the help text and the status message, which gives assistive tech a stable relationship from the start.

JavaScript logic

This version keeps the wiring simple and scales to more than one textarea on the page:

HTML
<script>
  function charCounter(textarea) {
    const maxLength = parseInt(textarea.getAttribute('maxlength'), 10);
    if (!maxLength) return;

    const currentLength = textarea.value.length;
    const progressWidth = Math.min((currentLength / maxLength) * 100, 100);

    const container = textarea.parentElement.querySelector('[data-counter]');
    if (!container) return;

    const progress = container.querySelector('[data-progress]');
    const remaining = container.querySelector('[data-remaining]');

    progress.style.width = `${progressWidth}%`;

    if (progressWidth <= 60) {
      progress.style.backgroundColor = 'green';
      remaining.textContent = `Character limit: ${maxLength}`;
    } else if (progressWidth < 85) {
      progress.style.backgroundColor = 'orange';
      remaining.textContent = `Character limit: ${maxLength}`;
    } else {
      progress.style.backgroundColor = 'red';
      remaining.textContent = `${maxLength - currentLength} characters remaining`;
    }
  }

  document.querySelectorAll('textarea[maxlength]').forEach((textarea) => {
    textarea.addEventListener('input', () => charCounter(textarea));
    charCounter(textarea);
  });
</script>

A few choices here are deliberate.

The handler reads from the DOM instead of a separate config object. That avoids drift between the hard limit and the displayed limit. It also means server-rendered HTML, static pages, and framework components can all share the same source of truth.

I also switched from assigning textarea.oninput to addEventListener('input', ...). Both work, but addEventListener plays better with other scripts and avoids overwriting another handler later.

Why `input` matters

Use the input event for counters. It catches typing, paste, drag-and-drop text, autocomplete, and many mobile keyboard changes that keyup misses.

That sounds small until you debug a field that updates for desktop typing but fails after paste on iOS. Character counters are tiny components, but they sit on top of messy input behavior across browsers and devices. input is the event built for value changes, so it is the safer default.

If you're already wiring custom submit handling and field validation by hand, this approach fits naturally with broader HTML forms with JavaScript patterns.

Keep maxlength in the HTML. The browser enforces the limit, the script reads the same value, and the form still behaves sensibly if JavaScript never runs.

Adding Advanced UX and Accessibility Features

A character counter can become part of the problem if it starts shouting the moment a user types the first letter.

On real forms, two details separate a demo from something you can ship. The counter should appear only when it helps, and screen readers should hear meaningful updates instead of a running play-by-play. Discussions in accessibility and design-system threads such as the GCDS character count issue keep returning to the same problems: counters shown too early, and live regions that announce far too often.

An infographic comparing the pros and cons of implementing an advanced textarea character counter for improved UX.

Delay visibility until it matters

For a 300 character field, a counter at character 1 usually adds noise, not clarity. A better default is to reveal it near the limit, often at 80 percent.

That gives users feedback when they need it and keeps the form quieter the rest of the time. It also matches how several design systems treat character counts: as progressive guidance, not permanent chrome.

Here's a threshold-based version in plain JS:

HTML
<script>
  function setupThresholdCounter(textarea) {
    const maxLength = parseInt(textarea.getAttribute('maxlength'), 10);
    const threshold = Math.floor(maxLength * 0.8);

    const counter = textarea.parentElement.querySelector('[data-counter]');
    const progress = counter.querySelector('[data-progress]');
    const status = counter.querySelector('[data-remaining]');

    function updateCounter() {
      const currentLength = textarea.value.length;
      const remaining = maxLength - currentLength;
      const progressWidth = (currentLength / maxLength) * 100;

      counter.hidden = currentLength < threshold;
      progress.style.width = `${progressWidth}%`;

      if (progressWidth <= 60) {
        progress.style.backgroundColor = 'green';
      } else if (progressWidth < 85) {
        progress.style.backgroundColor = 'orange';
      } else {
        progress.style.backgroundColor = 'red';
      }

      status.textContent = remaining <= (maxLength - threshold)
        ? `${remaining} characters remaining`
        : `Character limit: ${maxLength}`;
    }

    textarea.addEventListener('input', updateCounter);
    updateCounter();
  }
</script>

The 80 percent threshold is a product decision, not a law. Lower it for short fields where every character matters. Raise it for long-form inputs where the counter is secondary. The main goal is to avoid constant visual chatter.

Stop screen readers from hearing every keystroke

Visible updates and spoken updates should not follow the same rules.

A sighted user can handle a number changing on each input. A screen reader user gets a much worse experience if every keystroke triggers a polite announcement. The fix is simple: keep the visible counter live, but debounce the assistive announcement and reserve it for pauses or meaningful thresholds.

A better pattern is:

  • Update the visible count on every input
  • Keep the visible status out of the live region
  • Announce only after a short pause
  • Skip announcements until the counter is relevant

Debounced announcement pattern

Use one element for visible feedback and another for screen reader announcements.

HTML
<div class="char-counter" data-counter hidden>
  <div class="char-counter__bar">
    <div class="char-counter__progress" data-progress></div>
  </div>
  <p data-remaining aria-live="off">Character limit: 300</p>
  <p class="sr-only" data-announcement aria-live="polite"></p>
</div>

<script>
  function debounce(fn, delay) {
    let timeoutId;
    return (...args) => {
      clearTimeout(timeoutId);
      timeoutId = setTimeout(() => fn(...args), delay);
    };
  }

  function setupAccessibleCounter(textarea) {
    const maxLength = parseInt(textarea.getAttribute('maxlength'), 10);
    const threshold = Math.floor(maxLength * 0.8);
    const counter = textarea.parentElement.querySelector('[data-counter]');
    const visibleStatus = counter.querySelector('[data-remaining]');
    const announcement = counter.querySelector('[data-announcement]');
    const progress = counter.querySelector('[data-progress]');

    const announce = debounce((remaining) => {
      if (remaining <= (maxLength - threshold)) {
        announcement.textContent = `${remaining} characters remaining`;
      } else {
        announcement.textContent = '';
      }
    }, 400);

    function update() {
      const currentLength = textarea.value.length;
      const remaining = maxLength - currentLength;
      const progressWidth = (currentLength / maxLength) * 100;

      counter.hidden = currentLength < threshold;
      progress.style.width = `${progressWidth}%`;
      visibleStatus.textContent = `${remaining} characters remaining`;

      announce(remaining);
    }

    textarea.addEventListener('input', update);
    update();
  }
</script>

The trade-off is straightforward. Immediate announcements feel technically correct but create noise. Debouncing by 300 to 500ms usually feels better in practice because it waits for a natural pause in typing. I start at 400ms and adjust only if user testing shows it feels laggy.

Accessibility note: Live visual feedback and live spoken feedback are different concerns. If you route both through the same aria-live region, assistive tech users get flooded with repetitive updates.

If you want the rest of the form to follow the same logic, the guidance in form UX best practices for reducing friction pairs well with this threshold-first counter pattern.

Styling and Enforcing Character Limits

A production counter has two jobs. It must stop invalid input, and it must explain the limit before the user hits it.

Use the browser for enforcement. Use your UI for guidance. That split holds up better under real conditions, including slow devices, delayed script execution, and forms rendered inside third-party embeds where JavaScript can fail in awkward ways.

A computer screen displaying a form with a character counter error in a web interface

CSS that matches the counter states

Color can help users scan the field quickly, but it cannot carry the whole message. Keep the text count visible, then use color as a secondary cue. That gives sighted users fast feedback without leaving screen reader users behind.

HTML
<style>
  .char-counter {
    margin-top: 0.5rem;
  }

  .char-counter__bar {
    width: 100%;
    height: 0.5rem;
    background: #e5e7eb;
    border-radius: 999px;
    overflow: hidden;
  }

  .char-counter__progress {
    height: 100%;
    width: 0;
    background: green;
    transition: width 120ms ease, background-color 120ms ease;
  }

  textarea.limit-warning {
    border-color: orange;
  }

  textarea.limit-danger {
    border-color: red;
  }

  .sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    border: 0;
  }
</style>

I usually pair those classes with threshold logic instead of showing the warning state from the first keystroke. A counter that stays quiet until the user gets close to the limit feels less noisy, especially on mobile where every extra hint competes for vertical space. If you want more context on the field itself, this guide to textarea input patterns and behavior is a useful companion.

Accessibility wiring that people skip

The U.S. Web Design System character count component documents two details that matter in production. The count message should be associated to the field with aria-describedby, and the status element should contain meaningful fallback text in the initial HTML so the form still makes sense before JavaScript runs or if it fails to load. Their component guidance also targets WCAG 2.1 AA support. See the USWDS character count documentation.

Start with real text in the markup:

HTML
<textarea
  id="bio"
  name="bio"
  maxlength="300"
  aria-describedby="bio-help bio-status"></textarea>

<p id="bio-help">Short summary for your profile.</p>
<p id="bio-status">Character limit: 300</p>

That fallback text solves a boring but common failure mode. If hydration breaks, a CDN stalls, or an inline script gets stripped by a CMS, the field still exposes the limit.

Native limits and visual feedback should work together

Here is the practical split:

Concern Best tool
Prevent extra typing maxlength
Show current state JavaScript + CSS
Explain the limit to assistive tech aria-describedby
Handle script failure gracefully Fallback text in markup

The trade-off depends on the form. For a support ticket, profile bio, or anything backed by a strict database column, use maxlength and stop extra input at the browser level. For drafting tools, comments with soft recommendations, or editors that trim content later, warning-only can be a better fit because it preserves user flow.

A visual warning without maxlength is only advice. maxlength without a visible counter is hard enforcement with weak guidance. Production forms usually need both.

Integrating with React and Vue

Frameworks remove the manual DOM querying, which makes this component easier to reuse. The trade-off is that you need to keep state, accessibility text, and derived UI in sync inside the component.

React and Vue both handle this well. They just do it with different mental models.

React version with hooks

In React, the textarea value drives everything else. You don't manually update the DOM. You derive the count, threshold state, and progress width from state.

import { useEffect, useMemo, useState } from 'react';

export default function ProjectBriefForm() {
const maxLength = 300;
const threshold = Math.floor(maxLength * 0.8);

const [message, setMessage] = useState('');
const [announcement, setAnnouncement] = useState('');

const currentLength = message.length;
const remaining = maxLength - currentLength;
const progressWidth = (currentLength / maxLength) * 100;
const isVisible = currentLength >= threshold;

const progressColor = useMemo(() => {
if (progressWidth <= 60) return 'green';
if (progressWidth < 85) return 'orange';
return 'red';
}, [progressWidth]);

useEffect(() => {
const timeout = setTimeout(() => {
if (isVisible) {
setAnnouncement(${remaining} characters remaining);
} else {
setAnnouncement('');
}
}, 400);

Plain Text
return () => clearTimeout(timeout);

}, [remaining, isVisible]);

return (



<textarea
id="message"
name="message"
rows={6}
maxLength={maxLength}
value={message}
onChange={(e) => setMessage(e.target.value)}
aria-describedby="message-help message-status"
placeholder="Tell us what you're building"
/>

Include scope, constraints, and timeline.

Plain Text
  {isVisible && (
    <div>
      <div style={{ background: '#e5e7eb', height: '8px', borderRadius: '999px' }}>
        <div
          style={{
            width: `${progressWidth}%`,
            height: '8px',
            background: progressColor,
            borderRadius: '999px'
          }}
        />
      </div>
      <p id="message-status" aria-live="off">{remaining} characters remaining</p>
      <p className="sr-only" aria-live="polite">{announcement}</p>
    </div>
  )}

  <button type="submit">Submit</button>
</form>

);
}

Vue version with the Composition API

Vue feels similar here, but the reactive primitives make the derived values a bit more compact.