A live character counter is one of those small UI details that makes a big difference in usability. When users know how many characters they have left — and can see that number updating as they type — they write with more confidence and hit submit without guessing whether their input is too long.
Twitter made this pattern famous. Every form with a character limit should have one.
This guide builds a character counter from scratch, starting with the simplest version and adding everything that makes it production-ready — color changes as the limit approaches, a word counter alongside it, and a reusable pattern for multiple textareas.
The Basic Implementation
The simplest character counter needs three things: a textarea, a display element, and an input event listener.
<textarea id="bio" maxlength="200" placeholder="Write your bio..."></textarea>
<p id="char-count">0 / 200</p>
const textarea = document.getElementById("bio");
const charCount = document.getElementById("char-count");
const MAX = 200;
textarea.addEventListener("input", function () {
const current = this.value.length;
charCount.textContent = `${current} / ${MAX}`;
});
That is the complete core. Every time the user types or pastes, this.value.length gives the current character count and the display updates instantly.
The maxlength attribute on the textarea prevents input beyond the limit entirely. The counter shows how much of that limit has been used.
Showing Remaining Characters Instead
Some interfaces show “characters remaining” rather than “characters used”:
textarea.addEventListener("input", function () {
const remaining = MAX - this.value.length;
charCount.textContent = `${remaining} characters remaining`;
});
Which direction to count depends on the context. “Characters remaining” works well for short limits where running out is the primary concern — tweet-style inputs, SMS composers. “Characters used / max” works well for longer limits where the user wants to know progress.
Adding Color Changes as the Limit Approaches
The counter becomes dramatically more useful when it changes color to warn the user before they hit the limit. A three-stage color system — neutral, warning, danger — is the standard pattern:
<textarea id="bio" maxlength="200" placeholder="Write your bio..."></textarea>
<p id="char-count" class="counter">0 / 200</p>
.counter {
font-size: 0.85rem;
color: #64748b;
transition: color 0.2s;
text-align: right;
margin-top: 4px;
}
.counter.warning {
color: #f59e0b;
}
.counter.danger {
color: #ef4444;
font-weight: 600;
}
const textarea = document.getElementById("bio");
const charCount = document.getElementById("char-count");
const MAX = 200;
textarea.addEventListener("input", function () {
const current = this.value.length;
const remaining = MAX - current;
charCount.textContent = `${current} / ${MAX}`;
// Update color based on how close to the limit
charCount.classList.remove("warning", "danger");
if (remaining <= 10) {
charCount.classList.add("danger");
} else if (remaining <= 30) {
charCount.classList.add("warning");
}
});
At 30 characters remaining, the counter turns amber. At 10, it turns red. The thresholds are easy to adjust to match your limit and your design.
A Complete Styled Example
Here is everything combined into a copy-paste ready snippet:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Character Counter</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #f1f5f9;
font-family: "Segoe UI", sans-serif;
}
.field-wrapper {
width: 100%;
max-width: 480px;
}
label {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: #374151;
margin-bottom: 6px;
}
textarea {
width: 100%;
padding: 12px 14px;
border: 1.5px solid #d1d5db;
border-radius: 10px;
font-size: 1rem;
font-family: inherit;
color: #1e293b;
resize: vertical;
min-height: 120px;
outline: none;
transition: border-color 0.2s;
}
textarea:focus {
border-color: #3b82f6;
}
.counter-bar {
display: flex;
justify-content: flex-end;
margin-top: 6px;
}
.counter {
font-size: 0.8rem;
color: #94a3b8;
transition: color 0.2s;
}
.counter.warning { color: #f59e0b; }
.counter.danger { color: #ef4444; font-weight: 600; }
</style>
</head>
<body>
<div class="field-wrapper">
<label for="bio">Bio</label>
<textarea
id="bio"
maxlength="200"
placeholder="Tell us about yourself..."
></textarea>
<div class="counter-bar">
<span class="counter" id="char-count">0 / 200</span>
</div>
</div>
<script>
const textarea = document.getElementById("bio");
const charCount = document.getElementById("char-count");
const MAX = parseInt(textarea.getAttribute("maxlength"), 10);
textarea.addEventListener("input", function () {
const current = this.value.length;
const remaining = MAX - current;
charCount.textContent = `${current} / ${MAX}`;
charCount.classList.remove("warning", "danger");
if (remaining <= 10) charCount.classList.add("danger");
else if (remaining <= 30) charCount.classList.add("warning");
});
</script>
</body>
</html>
Notice parseInt(textarea.getAttribute("maxlength"), 10) — this reads the limit directly from the HTML attribute rather than hardcoding it in JavaScript. Change maxlength in the HTML and the counter updates automatically.
Adding a Progress Bar
A visual progress bar alongside the counter gives users an at-a-glance indication of how full the field is:
<div class="field-wrapper">
<label for="bio">Bio</label>
<textarea id="bio" maxlength="200" placeholder="Tell us about yourself..."></textarea>
<div class="counter-bar">
<div class="progress-track">
<div class="progress-fill" id="progress-fill"></div>
</div>
<span class="counter" id="char-count">0 / 200</span>
</div>
</div>
.counter-bar {
display: flex;
align-items: center;
gap: 10px;
margin-top: 6px;
}
.progress-track {
flex: 1;
height: 4px;
background: #e2e8f0;
border-radius: 99px;
overflow: hidden;
}
.progress-fill {
height: 100%;
width: 0%;
background: #3b82f6;
border-radius: 99px;
transition: width 0.2s ease, background-color 0.2s ease;
}
const progressFill = document.getElementById("progress-fill");
textarea.addEventListener("input", function () {
const current = this.value.length;
const remaining = MAX - current;
const pct = (current / MAX) * 100;
charCount.textContent = `${current} / ${MAX}`;
progressFill.style.width = `${pct}%`;
charCount.classList.remove("warning", "danger");
progressFill.style.backgroundColor = "#3b82f6";
if (remaining <= 10) {
charCount.classList.add("danger");
progressFill.style.backgroundColor = "#ef4444";
} else if (remaining <= 30) {
charCount.classList.add("warning");
progressFill.style.backgroundColor = "#f59e0b";
}
});
The progress bar and counter change color together, giving a consistent visual warning across both elements as the limit approaches.
Adding a Word Counter
Some contexts care more about words than characters — blog post excerpts, meta descriptions, academic submissions. A word counter runs alongside the character counter:
function countWords(str) {
return str.trim() === "" ? 0 : str.trim().split(/\s+/).length;
}
textarea.addEventListener("input", function () {
const chars = this.value.length;
const words = countWords(this.value);
charCount.textContent = `${chars} / ${MAX} characters · ${words} words`;
});
/\s+/ splits on any whitespace — single spaces, multiple spaces, tabs, and newlines. The empty string check prevents .split() from returning [""] when the textarea is blank, which would incorrectly show 1 word.
Handling Paste Events
The input event fires for both typing and pasting, so paste behavior is handled automatically. However, if a paste would exceed the maxlength limit, the browser truncates the pasted content. You may want to inform the user when this happens:
textarea.addEventListener("paste", function () {
// After paste, check if content was truncated
setTimeout(() => {
if (this.value.length === MAX) {
charCount.classList.add("danger");
// Optional: show a one-time notice
console.log("Content was trimmed to fit the character limit.");
}
}, 0);
});
The setTimeout with 0 milliseconds defers the check until after the paste has been processed and the value updated. Without it, this.value still reflects the pre-paste content.
Reusable Counter for Multiple Textareas
When a page has several fields with character limits, a reusable function handles all of them:
function attachCharCounter(textareaEl, counterEl, options = {}) {
const max = parseInt(textareaEl.getAttribute("maxlength"), 10);
const warningAt = options.warningAt ?? Math.round(max * 0.15);
const dangerAt = options.dangerAt ?? Math.round(max * 0.05);
const showWords = options.showWords ?? false;
function update() {
const current = textareaEl.value.length;
const remaining = max - current;
let text = `${current} / ${max}`;
if (showWords) {
const words = textareaEl.value.trim() === ""
? 0
: textareaEl.value.trim().split(/\s+/).length;
text += ` · ${words} words`;
}
counterEl.textContent = text;
counterEl.classList.remove("warning", "danger");
if (remaining <= dangerAt) counterEl.classList.add("danger");
else if (remaining <= warningAt) counterEl.classList.add("warning");
}
textareaEl.addEventListener("input", update);
update(); // Initialize on load
}
// Attach to multiple fields
attachCharCounter(
document.getElementById("bio"),
document.getElementById("bio-counter")
);
attachCharCounter(
document.getElementById("summary"),
document.getElementById("summary-counter"),
{ showWords: true, warningAt: 50, dangerAt: 10 }
);
Each counter calculates its warning thresholds as a percentage of the limit by default — 15% and 5%. Override them with explicit values when the defaults do not fit. Calling update() immediately on initialization populates the counter correctly on page load, which matters if the field has a default value or is pre-filled from a server.
Initializing With Pre-Filled Content
If a textarea has a default value — an edit form with existing content, a pre-populated description field — the counter should reflect the existing length immediately rather than starting at zero:
// Option 1: Fire the input event manually on load
textarea.dispatchEvent(new Event("input"));
// Option 2: Call the update function once at the end of setup
updateCounter(); // wherever your update logic lives
Either approach populates the counter correctly before the user types anything.
Handling the maxlength Attribute vs. Manual Enforcement
The maxlength HTML attribute does the enforcement for you — the browser prevents additional input once the limit is reached. If you are enforcing the limit manually in JavaScript (for example, to allow the user to type past the limit but flag it with an error), remove maxlength from the HTML and handle it in the event listener:
const SOFT_MAX = 200;
textarea.addEventListener("input", function () {
const current = this.value.length;
const remaining = SOFT_MAX - current;
charCount.textContent = `${current} / ${SOFT_MAX}`;
if (current > SOFT_MAX) {
charCount.classList.add("danger");
textarea.classList.add("over-limit");
} else {
charCount.classList.remove("danger");
textarea.classList.remove("over-limit");
}
});
A soft limit warns the user but does not block typing — useful for interfaces that want to show the overage visually before blocking form submission. The submit handler then checks the length and prevents submission if it exceeds the limit.
Tips for Building Character Counters
Read maxlength from the HTML attribute. Hardcoding the limit in JavaScript separately from the HTML creates a maintenance problem — change one and forget the other. Parse it from the attribute at runtime and you only have one source of truth.
Use the input event, not keyup. The input event fires on every change to the value — typing, pasting, cutting, autocomplete, drag and drop. The keyup event only fires on keyboard input. Use input.
Initialize the counter on page load. If the field has a default value, the counter should reflect it immediately. Dispatch an input event or call your update function once at the end of setup.
Place the counter below and right-aligned. Users scan from left to right. The character count as the last thing they see — right-aligned below the textarea — feels natural and does not interrupt reading.
Keep the counter visible when the field is empty. Showing 0 / 200 rather than hiding the counter gives users an immediate sense of the available space before they start typing.
A live character counter is a textarea, an input event listener, and a display element that updates on every change. Add color transitions at warning and danger thresholds. Read the limit from maxlength. Initialize it on page load for pre-filled fields.
The reusable function pattern makes adding a counter to any field a single function call. Once it is built, it takes seconds to apply to as many textareas as you need.