minlength and maxlength Character Limits
Why does a field with minlength="10" accept a three-character value set by script, why does maxlength="280" stop a user at 279 visible characters when they type an emoji, and why does a pasted 400-character message silently lose its ending? The length attributes look like the simplest constraints in HTML, but they count UTF-16 code units rather than characters, one of them only reports after user edits, and the other truncates instead of reporting. This recipe explains exactly how minlength and maxlength behave in the Constraint Validation API, when to rely on them, when to replace maxlength with a soft limit and a live counter, and how to keep the browser’s count, your counter and the server’s limit in agreement.
When to Use the Length Attributes
Use minlength and maxlength for text limits that are real product rules, and rely on their native behaviour when:
- The limit protects storage or display — a 100-character name column, a 280-character post, a 12-character minimum password.
- The content is mostly ASCII or BMP text, where code units and characters coincide.
- You want free enforcement before JavaScript loads, as part of the site’s canonical
<form novalidate>baseline.
Replace the hard maxlength with a soft limit when users compose longer text (messages, bios, reviews), where silent truncation is worse than letting the user see they are over and edit. The companion attributes for other input types — min, max, step, pattern — are covered in HTML5 input types and attributes.
Minimal Working Length Validation
<form id="profile" novalidate>
<label for="bio">Short bio</label>
<p id="bio-hint" class="hint">Between 20 and 160 characters.</p>
<textarea id="bio" name="bio" minlength="20" data-max="160" rows="4"
aria-describedby="bio-hint bio-count bio-err"></textarea>
<p id="bio-count" class="counter" aria-live="off">0 of 160 characters</p>
<p id="bio-err" class="field-error" hidden></p>
<p id="bio-status" class="visually-hidden" role="status"></p>
<button type="submit">Save</button>
</form>
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
const visibleLength = (s: string) => [...segmenter.segment(s)].length;
const form = document.querySelector<HTMLFormElement>("#profile")!;
const bio = form.querySelector<HTMLTextAreaElement>("#bio")!;
const count = form.querySelector<HTMLElement>("#bio-count")!;
const status = form.querySelector<HTMLElement>("#bio-status")!;
const error = form.querySelector<HTMLElement>("#bio-err")!;
const MAX = Number(bio.dataset.max);
const MIN = bio.minLength;
let lastBand = "";
function check(show: boolean): void {
const n = visibleLength(bio.value);
// Soft maximum: allow typing past the limit, report it clearly instead of truncating.
let message = "";
if (n > MAX) message = `Your bio is ${n - MAX} character${n - MAX === 1 ? "" : "s"} too long. Shorten it to ${MAX} or fewer.`;
else if (n > 0 && n < MIN) message = `Your bio needs at least ${MIN} characters. You have ${n}.`;
bio.setCustomValidity(message); // covers the programmatic-value gap in minlength too
count.textContent = `${n} of ${MAX} characters`;
count.classList.toggle("over", n > MAX);
// Announce only when crossing thresholds, never on every keystroke.
const band = n > MAX ? "over" : n > MAX - 20 ? "near" : "ok";
if (band !== lastBand) {
status.textContent = band === "over" ? `Over the limit by ${n - MAX}.` : band === "near" ? `${MAX - n} characters left.` : "";
lastBand = band;
}
if (show) {
error.textContent = message;
error.hidden = !message;
bio.toggleAttribute("aria-invalid", Boolean(message));
}
}
bio.addEventListener("input", () => check(!error.hidden));
bio.addEventListener("blur", () => check(true));
form.addEventListener("submit", (e) => {
check(true);
if (!form.reportValidity()) e.preventDefault();
});
check(false);
Three deliberate choices: the maximum is soft (data-max instead of maxlength), so pasting a long text shows the problem instead of silently cutting it; length is counted in graphemes, so “👩🏽💻” is one character as the user sees it; and the minimum is enforced with setCustomValidity, which — unlike native tooShort — also applies to values restored or set by script.
Length Attribute Reference
| Attribute / API | Counts | Reports | Notes |
|---|---|---|---|
minlength="n" |
UTF-16 code units | validity.tooShort after user edits |
Empty values are not “too short” — pair with required |
maxlength="n" |
UTF-16 code units | Blocks input; tooLong rarely |
Truncates paste; IME composition can exceed briefly |
value.length |
UTF-16 code units | — | Same unit as the attributes |
[...value].length |
Code points | — | Closer, still wrong for emoji sequences |
Intl.Segmenter graphemes |
User-perceived characters | — | Use for counters and messages |
setCustomValidity(msg) |
whatever you choose | customError |
Enforces rules in your unit, for any value source |
Verification Steps
import { test, expect } from "@playwright/test";
test("soft maximum reports instead of truncating", async ({ page }) => {
await page.goto("/profile");
const bio = page.getByLabel("Short bio");
await bio.fill("x".repeat(200));
await expect(bio).toHaveValue("x".repeat(200)); // nothing silently removed
await page.getByRole("button", { name: "Save" }).click();
await expect(page.locator("#bio-err")).toHaveText("Your bio is 40 characters too long. Shorten it to 160 or fewer.");
});
Edge Cases and Failure Modes
minlength and programmatic values. tooShort is only set when the user edited the value. Values restored from drafts, prefilled by the server or set in tests never trigger it, so a too-short saved value submits without complaint. Enforce minimums with setCustomValidity or on the server.
Empty fields pass minlength. An empty field is not “too short” — it is “missing”. Add required when the field must be filled, or a minimum-length rule silently accepts empty input.
Hard maxlength and paste. Pasting 400 characters into maxlength="280" keeps the first 280 without any message. Users often do not notice the loss until after submitting. Use a soft maximum for composed text.
Server limits in bytes. Databases often limit bytes (VARCHAR(255) in some configurations, or UTF-8 byte limits in APIs). A 160-grapheme bio in Arabic or with emoji can exceed a byte limit. Choose the unit on the server deliberately and state limits to users in characters, with generous storage.
Choosing Between a Hard and a Soft Maximum
A hard maxlength is right when the value is short and typed, not composed — codes, usernames, postcodes — because the user notices immediately when typing stops and there is little text to lose. A soft maximum is right for anything composed or pasted: messages, descriptions, reviews, addresses copied from elsewhere. The soft version needs three pieces working together: a visible counter that states the limit, a threshold announcement for screen reader users as they approach it, and a validity error when over, reported through the canonical reportValidity() path on submit. The counter pattern overlaps with the requirement checklists in live password requirements checklist: information is always visible, but announcements happen only at meaningful moments.
Styling the Counter Without Relying on Colour
Counters usually turn red when the limit is exceeded, and that colour must never be the only signal. The counter text itself already carries the information (“172 of 160 characters”), and the validity error adds an explicit instruction; the colour is reinforcement. Keep the counter visually tied to the field — directly below it, right-aligned is conventional — and reference it from aria-describedby so it is read with the field on focus, while aria-live="off" prevents it from speaking on every keystroke.
Keeping Client and Server Limits in Agreement
The browser, your counter and the server must all use the same number and the same unit, or users hit a band where the page says “158 of 160” and the server says “too long”. Define limits in one shared module with an explicit unit — graphemes for user-facing text is the kindest choice — and apply the same counting function on the server. Where a storage layer imposes a byte limit, set it comfortably above the worst case for your grapheme limit (a grapheme can be dozens of bytes in extreme emoji sequences), or enforce the byte limit as a separate, clearly worded rule. The shared-module approach is laid out in shared client–server schemas.
// shared/limits.ts
export const LIMITS = { bio: { min: 20, max: 160, unit: "graphemes" } } as const;
export const graphemes = (s: string) => [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(s)].length;
Frequently Asked Questions
Why doesn't minlength trigger when I set the value with JavaScript?
The tooShort flag is only set after the user edits the value. Script-assigned, prefilled or restored values never trigger it. Enforce minimums with setCustomValidity or on the server if values can come from other sources.
Does maxlength count emoji correctly?
No. It counts UTF-16 code units, so a single emoji can count as two or more, and emoji sequences as many. Count graphemes with Intl.Segmenter for user-facing limits and counters.
Why was my pasted text cut off?
maxlength truncates pasted text to the limit without any message. For composed text, use a soft limit: allow the extra characters, show a counter and report an error until the text is shortened.
Does an empty field fail minlength?
No. An empty value is not considered too short. Add required if the field must be filled in.
Related Guides
- HTML5 Input Types & Attributes — the other native constraints.
- Reading ValidityState Flags for Granular Errors — tooShort, tooLong and friends.
- Unicode-Aware Name Field Validation — grapheme counting for names.
- Writing Clear Inline Error Message Copy — wording length errors helpfully.