
Build a Textarea Character Counter: JS, React, Vue Guide
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:
- Use the native
maxlengthattribute for the hard stop. - Update on
input, not keyboard-only events. - Delay visibility until the user is close enough to care.
- 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.

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:
<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:
<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
maxlengthin 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.

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:
<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.
<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-liveregion, 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.

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.
<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:
<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);
return () => clearTimeout(timeout);}, [remaining, isVisible]);
return (
{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.
<div v-if="isVisible">
<div style="background:#e5e7eb;height:8px;border-radius:999px;">
<div
:style="{
width: `${progressWidth}%`,
height: '8px',
background: progressColor,
borderRadius: '999px'
}"
></div>
</div>
<p id="message-status" aria-live="off">{{ remaining }} characters remaining</p>
<p class="sr-only" aria-live="polite">{{ announcement }}</p>
</div>
<button type="submit">Submit</button>Which one is easier to maintain
React makes the state flow explicit. That's useful when your form logic is already built around hooks, reducers, or shared field components.
Vue keeps the template very readable. If you want the counter logic close to the markup and don't need much abstraction, it often feels lighter.
Both are cleaner than imperative DOM code once the counter becomes a shared component. If you're refining copy, helper text, and textarea behavior more broadly, this guide on text area input patterns is a useful companion.
Finalizing and Deploying Your Form
The counter works. The textarea feels polished. Then the form goes live and people start submitting from real devices, slow networks, password managers, and bots.
That is where deployment details matter. A production-ready counter improves input quality on the frontend, but the full form still needs a safe submission path and server-side checks.

A complete HTML form example
Here's a realistic static form that includes the counter and posts to a hosted endpoint:
This setup fits static and JAMstack sites well because the browser can post directly to a hosted form endpoint. The trade-off is simple. You avoid building and maintaining your own submission API, but you need to verify that the provider supports the things your specific form needs.
That usually means checking more than email delivery. File uploads, spam filtering, webhook routing, GDPR controls, and domain authentication all affect whether the form is ready for production. Some hosted backends support file uploads up to 5MB per submission, reCAPTCHA v2/v3, Cloudflare Turnstile, Altcha, honeypot fields, and custom-domain email with SPF, DKIM, and DMARC.
What client-side validation can't do
A character counter helps people stay within the limit. maxlength stops accidental overflow in normal use. A disabled submit button can guide the happy path.
None of that protects the backend.
Anyone can bypass client-side rules with devtools, a script, or a direct POST request. The API still needs to validate every field, including hidden inputs and anything passed through headers or query parameters. As this API security guide explains, the backend should enforce expected types, lengths, formats, and ranges, and reject fields it does not recognize instead of automatically accepting them.
Static deployments add another constraint. If you place an API key in browser code, users can read it. API keys are insecure as a sole protection mechanism on static sites because they are exposed, carry no user identity, and do not give you fine-grained access control, as covered in this API key security overview. If you need tighter control, a small FaaS proxy is the usual next step. It can check origin, rate limits, or signed data before forwarding the request, which is the pattern discussed in this static-site API key answer.
Once the frontend component is solid, the next job is handling submissions without turning a simple form into backend maintenance work.
If you want a form backend for a static or JAMstack site without building your own submission pipeline, Static Forms is worth a look. You point the form at their endpoint, keep the character counter and other UX behavior on the frontend, and get email delivery, dashboard storage, webhook options, spam protection, GDPR tooling, and custom-domain email support without maintaining server-side form infrastructure yourself.
Related Articles
Forms with Checkboxes: A Developer's Complete Guide
Build accessible and robust forms with checkboxes. This guide covers HTML patterns, validation, styling, and implementation in React, Vue, Next.js, and more.
Mastering Button Tags Html for Modern Web Development
Master button tags html in 2026! Explore syntax, attributes (type/disabled), form behavior, accessibility, and React/Vue usage for web success.
Fix Your Form Abandonment Rate: A Developer's Guide
Learn to calculate and reduce your form abandonment rate with actionable UX, performance, and validation tips. A developer's guide with code examples.