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.

How minlength and maxlength differ Two columns comparing the behaviour of the minlength and maxlength attributes, including when they report errors and how they treat programmatic values. minlength • sets validity.tooShort • only after the user edits the value • script-set values never trigger it ✓ reported by reportValidity with a message maxlength • blocks typing beyond the limit • sets tooLong only for user edits past the limit (rare) ✗ paste is truncated silently ✗ counts UTF-16 units, not characters
minlength reports an error after user edits; maxlength mostly prevents the problem by blocking input, so it rarely reports at all.

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.

One emoji, three different lengths A bar chart showing how the woman technologist emoji with skin tone counts as one grapheme, four code points and seven UTF-16 code units. Graphemes (what users see) 1 counted Code points ([...s].length) 4 counted UTF-16 units (maxlength, .length) 7 counted
maxlength and String.length count UTF-16 units; users count graphemes. The same emoji is 1, 4 or 7 long depending on who counts.

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.

Soft character limit states A field with a soft limit moves from empty to within limit, to near the limit where a threshold announcement is made, to over the limit where it becomes invalid, and back as the user edits. empty within limit near limit over limit typing 20 left (announce) past max (announce, invalid) shortened shortened
The counter updates silently on every keystroke; only crossing into "near" or "over" is announced, and only "over" makes the field invalid.

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.

← Back to HTML5 Input Types & Attributes