CSS Character Count Indicator

Input border changes color based on validity using :user-invalid and :user-valid with minlength/maxlength.

Published June 11, 2026 Intermediate 8 min read

A CSS character count indicator cannot show a number, because no stylesheet can read how many characters are in a field. What it can do is style the field against the limits set in the HTML. minlength and maxlength feed the browser's constraint validation, and :user-valid and :user-invalid expose the result, so the border and the hint text can react to whether the length is acceptable without a single line of script.

The pair of :user- pseudo-classes matter for when they fire, not just whether. Plain :invalid matches a field that is too short from the moment the page loads, so a fresh form arrives already marked wrong. The :user- versions wait until the field has been edited and left. Two more versions follow: a textarea with three separate messages for empty, too short and long enough, and a field where going over the limit is visible instead of silently truncated.

Type in either field

Min 2 characters Max 40
Min 20 characters Max 160

HTML

<div class="cc-wrap">
  <div class="cc-field">
    <label for="cc-name">Name (min 2, max 40 chars)</label>
    <input type="text" id="cc-name" minlength="2" maxlength="40" placeholder="Enter your name">
    <div class="cc-meta">
      <span>Min 2 characters</span>
      <span>Max 40</span>
    </div>
  </div>
  <div class="cc-field">
    <label for="cc-bio">Bio (min 20, max 160 chars)</label>
    <textarea id="cc-bio" minlength="20" maxlength="160" placeholder="Write a short bio..."></textarea>
    <div class="cc-meta">
      <span>Min 20 characters</span>
      <span>Max 160</span>
    </div>
  </div>
</div>

CSS

.cc-wrap {
  display: flex;
  flex-direction: column;
  gap: 1.5rem;
  max-width: 400px;
  margin: 0 auto;
}

.cc-field {
  display: flex;
  flex-direction: column;
  gap: .4rem;
}

label {
  font-size: .85rem;
  color: #88888f;
  font-weight: 500;
}

input,
textarea {
  width: 100%;
  padding: .75rem 1rem;
  background: #1c1c1e;
  border: 2px solid #2a2a2d;
  border-radius: 8px;
  color: #f0f0f0;
  font-family: system-ui, sans-serif;
  font-size: .95rem;
  transition: border-color .2s;
  outline: none;
}

textarea {
  resize: vertical;
  min-height: 80px;
}

input:focus,
textarea:focus {
  border-color: #b8ff57;
}

/* :user-valid and :user-invalid only fire after the field has been touched */
input:user-valid,
textarea:user-valid {
  border-color: #57d9a3;
}

input:user-invalid,
textarea:user-invalid {
  border-color: #ff6b6b;
}

.cc-meta {
  display: flex;
  justify-content: space-between;
  font-size: .75rem;
  color: #88888f;
}

input:user-valid ~ .cc-meta,
textarea:user-valid ~ .cc-meta {
  color: #57d9a3;
}

input:user-invalid ~ .cc-meta,
textarea:user-invalid ~ .cc-meta {
  color: #ff6b6b;
}

Other ways to build it

Empty, too short, and long enough as three separate messages

A single red border says something is wrong without saying what. :placeholder-shown separates an empty field from one holding text that is too short, which gives three states to write three different messages against. All three are in the markup and the stylesheet decides which one is displayed, so nothing chooses the wording at runtime. Type one word into the box, then keep going past twenty characters.

Between 20 and 160 characters

Keep going, that is under 20 characters

Long enough

HTML

<div class="cc-field cc-three">
  <label for="bio">Short bio</label>
  <textarea id="bio" minlength="20" maxlength="160" required
            placeholder="A sentence or two about yourself"></textarea>
  <p class="cc-state cc-state-empty">Between 20 and 160 characters</p>
  <p class="cc-state cc-state-short">Keep going, that is under 20 characters</p>
  <p class="cc-state cc-state-ok">Long enough</p>
</div>

CSS

.cc-state {
  display: none;
  font-size: .75rem;
}

/* nothing typed yet */
.cc-three textarea:placeholder-shown ~ .cc-state-empty {
  display: block;
  color: #88888f;
}

/* has content, but not enough of it */
.cc-three textarea:not(:placeholder-shown):invalid ~ .cc-state-short {
  display: block;
  color: #ff9040;
}

.cc-three textarea:not(:placeholder-shown):valid ~ .cc-state-ok {
  display: block;
  color: #57d9a3;
}

Going over the limit visibly, instead of being truncated

maxlength never lets a field exceed its limit, which sounds helpful until somebody pastes three paragraphs into a 28 character field and the browser keeps the first 28 without saying anything. Swapping it for pattern=".{0,28}" allows the text in and marks the field invalid instead, so the overrun can be explained. This field starts with a value already over the limit, which is why the rule uses :invalid rather than :user-invalid: on an empty form, the :user- version is still the right choice.

Up to 28 characters

That is longer than 28 characters

HTML

<div class="cc-field cc-soft">
  <label for="headline">Headline</label>
  <!-- pattern, not maxlength: the overrun is allowed in so it can be seen -->
  <input type="text" id="headline" pattern=".{0,28}" required
         value="A headline that runs well past the limit">
  <p class="cc-state cc-state-under">Up to 28 characters</p>
  <p class="cc-state cc-state-over">That is longer than 28 characters</p>
</div>

CSS

.cc-soft input:invalid {
  border-color: #ff9040;
}

.cc-soft input:invalid ~ .cc-state-over {
  display: block;
  color: #ff9040;
}

.cc-soft input:valid ~ .cc-state-under {
  display: block;
  color: #57d9a3;
}

.cc-soft input:invalid ~ .cc-state-under {
  display: none;
}

How it works

CSS cannot read a live character count, but it can style a field against its validity constraints. maxlength stops typing past the limit and minlength sets the floor. :user-valid and :user-invalid fire only after the field has been interacted with, which keeps untouched inputs from turning red. The sibling ~ combinator lets the hint text change color to match.

minlength behaves differently from most constraints. The browser only reports a value as too short once it has been edited by the person filling the form, so a field arriving prefilled with three characters against a minlength of twenty is not invalid until somebody types in it. That is deliberate, and it is why the pattern below can show an empty state, a too short state and a valid state without any of them fighting.

maxlength is not a validation constraint in the same way. It stops the extra characters ever being entered, so the field cannot exceed it and no pseudo-class ever matches. The consequence catches people out: paste four hundred words into a field with maxlength="160" and the browser silently keeps the first 160 and discards the rest, with no message and no border color change. The second variant swaps maxlength for a pattern so going over is visible rather than silent.

The hint text is a sibling of the input, selected with ~. Because sibling combinators only look forward, the hint has to be written after the field inside the same wrapper. That one ordering rule is what makes this pattern work, and it is the same constraint behind the hints on input validation and the label position on floating labels.

:placeholder-shown is the piece that separates empty from wrong. It matches a field whose placeholder is still visible, which means nothing has been typed. Combining :not(:placeholder-shown) with :invalid gives a selector meaning has content but the content is not acceptable, which is the state a counter would otherwise be needed for.

A live number genuinely does need script. <output> and an input event handler are three lines, and for a field with a hard limit such as a tweet or an SMS, that number is worth having. Everything on this page is the useful part that comes free: the states, the colors and the messages, all of which stay in place whether or not a bundle loaded.

CSS properties used

:user-valid
Matches a field that passes its constraints, but only after it has been edited and left, or the form submitted.
:user-invalid
The same timing applied to a field that fails. This is what stops a fresh form arriving covered in red.
:placeholder-shown
Matches while the field is empty and its placeholder is visible. Negated, it means the field has content.
~ (general sibling)
Reaches the hint text from the input. Only looks forward, so the hint has to be written after the field.
minlength
Sets the floor. Only reported as too short once the value has been edited by the person filling the form.
pattern
A regular expression constraint. .{0,28} marks anything longer than 28 characters invalid without truncating it.

Browser support

FeatureChromeFirefoxSafariEdge
form validation10410.112
minlength attribute405110.117
maxlength attribute445.112
pattern attribute10410.112
:placeholder-shown4751979

Figures come from Can I Use. There is no Can I Use entry for :user-valid and :user-invalid, so no versions are quoted for them: all four major browsers support them now, Firefox having had the same behavior first under the prefixed name :-moz-ui-invalid, with Chrome and Safari arriving much later. Check the current position on MDN before making them the only signal. A browser that does not know them drops the rule entirely, leaving an unstyled but working field, and :not(:placeholder-shown):invalid is the older approximation to fall back to.

Accessibility notes

Color carries no information for a screen reader. A border turning red and a hint turning red are both purely visual, so the words in the hint have to say what is wrong on their own. Associate that hint with the field using aria-describedby on a real form, and it will be read out when focus arrives rather than being missed entirely.

Write the limits into the label or the hint, not only into the attributes. Somebody who cannot see the field turn red still needs to know that a bio has to be at least twenty characters, and minlength alone is announced by nothing. The demo puts Min 20 characters and Max 160 in text under the field, which is the right place for it.

The :user- timing is an accessibility improvement in itself. Marking every empty field invalid on load means a screen reader user hears a list of errors before touching anything, which trains people to ignore the error state. Waiting until a field has been left gives each error a cause the reader can connect it to.

The base rule sets outline: none and shows focus as a border color, which is also the color carrying validity. A focused invalid field then has to choose which state to show. This page's stylesheet adds a separate :focus-visible ring with an offset so the two do not compete.

maxlength truncating a paste is an accessibility problem as much as a usability one. Nothing is announced, nothing changes visually, and the reader has no way to know part of what they pasted is gone. Where the limit matters, the second variant's approach of flagging the overrun is kinder than silently cutting it.

What you can build with it

  • Bio and description fields. A minimum that stops one word answers and a maximum that keeps the layout predictable.
  • Usernames and handles. A length floor plus a pattern for the allowed characters, both checked by the browser.
  • Message and comment boxes. A visible limit under the field, with the border confirming the length once the reader tabs away.
  • Social post composers. Where going over the limit needs to be obvious rather than silently trimmed, which is the second variant.
  • Search and filter inputs with a floor. A minlength of three stops a one letter query being submitted at all.

Mistakes worth avoiding

  • Expecting CSS to display the count. No stylesheet can read the value of a field, so the number itself needs script. Everything else on this page does not.
  • Using maxlength where the overrun matters. Pasted text is truncated with no message, so the reader believes the whole thing went in.
  • Using :invalid instead of :user-invalid on an empty form. Every field is marked wrong on page load, before anybody has typed.
  • Writing the hint before the input. The ~ combinator only looks forward, so the hint never changes color and the field appears to validate silently.
  • Assuming minlength invalidates a prefilled short value. It only reports as too short once the value has been edited, so a server rendered value below the floor passes until somebody touches it.

Frequently asked questions

Can CSS show a live character count?
No. A stylesheet cannot read the value of a form field, so the number has to come from script, typically an input event writing to an <output> element. What CSS can do without any script is style the field and its hint according to whether the length satisfies minlength, maxlength or a pattern.
What is the difference between minlength and maxlength?
maxlength prevents the characters being entered at all, so the field can never exceed it and no pseudo-class ever fires. minlength allows the value and reports it as too short, which is a validity state CSS can style. They are not symmetrical, and that catches people out.
Why does my field not turn red when it is too short?
Most likely because the value has not been edited. The browser only reports a value as too short once the person filling the form has changed it, so a prefilled short value passes. If the field has been typed in, check you are using :user-invalid and not just :invalid, since the first one also waits for the field to be left.
How do I warn someone they are over a character limit?
Replace maxlength with a pattern such as .{0,160}. The extra characters can then be typed, the field matches :invalid, and a hint can explain the overrun. maxlength gives no warning because it never lets the value get there.
Does maxlength stop text being pasted in?
It does not block the paste, it truncates it. The first however many characters are kept and the rest are dropped silently, with no message and no change in the field's appearance.