CSS Character Count Indicator
Input border and hint text change color based on minlength and maxlength validity, styled with :user-invalid and :user-valid.
- Level
- Intermediate
- Published
- Updated
- Reading time
- 7 min
Try it
4 live demos
Type in either field
Other ways to build it
Between 20 and 160 characters
Keep going, that is under 20 characters
Long enough
Empty, too short, and long enough as three separate messages
See the codeUp to 28 characters
That is longer than 28 characters
Going over the limit visibly, instead of being truncated
See the code- Not empty (met) (not met)
- At least 8 characters (met) (not met)
- 12 or more is stronger (met) (not met)
- No more than 20 characters (met) (not met)
- 8 to 20 characters (met) (not met)
A requirements checklist beside the field
See the codeBuild it
8 snippets to copy
CSS Character Count Indicator
HTML
<div class="cc-wrap">
<div class="cc-field">
<label for="cc-name">Name</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</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;
}
/* label, field and hint sit tight as one group */
.cc-field {
display: flex;
flex-direction: column;
gap: .15rem;
}
label {
font-size: .85rem;
line-height: 1.1;
color: #f0f0f0;
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;
}
/* the hint is smaller and muted, so it reads as help, not as a label */
.cc-meta {
display: flex;
justify-content: space-between;
font-size: .7rem;
line-height: 1.2;
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;
}
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.
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.
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;
}
A requirements checklist beside the field
The reader sees four requirement lines that are always visible, switching between a check and an X as they type: not empty, at least 8 characters, 12 or more is stronger, and no more than 20 characters. The field uses field-sizing: content on a monospace font, which makes the field's width follow the typed length in ch units. A size container sitting in the same grid column measures that width. Instead of maxlength, pattern allows typing a 21st character so the last line can switch to X.
HTML
<!-- icons: Font Awesome Free (CC BY 4.0), inline so they need no font file -->
<div class="cc-pw-wrap">
<div class="cc-pw">
<label class="cc-pw-label" for="password">Password</label>
<!-- placeholder is one space, which makes :placeholder-shown work -->
<input class="cc-pw-input" id="password" name="password" type="password"
required minlength="8" pattern=".{0,20}" placeholder=" "
autocomplete="new-password" aria-describedby="password-rules">
<div class="cc-pw-count">
<ul class="cc-pw-rules" id="password-rules">
<li class="cc-pw-rule cc-pw-rule-filled">
<svg class="cc-pw-icon" aria-hidden="true" focusable="false"><use class="cc-pw-yes" href="#cc-pw-check"/><use class="cc-pw-no" href="#cc-pw-xmark"/></svg>
Not empty<span class="cc-pw-met"> (met)</span><span class="cc-pw-unmet"> (not met)</span>
</li>
<li class="cc-pw-rule cc-pw-rule-min">
<svg class="cc-pw-icon" aria-hidden="true" focusable="false"><use class="cc-pw-yes" href="#cc-pw-check"/><use class="cc-pw-no" href="#cc-pw-xmark"/></svg>
At least 8 characters<span class="cc-pw-met"> (met)</span><span class="cc-pw-unmet"> (not met)</span>
</li>
<li class="cc-pw-rule cc-pw-rule-strong">
<svg class="cc-pw-icon" aria-hidden="true" focusable="false"><use class="cc-pw-yes" href="#cc-pw-check"/><use class="cc-pw-no" href="#cc-pw-xmark"/></svg>
12 or more is stronger<span class="cc-pw-met"> (met)</span><span class="cc-pw-unmet"> (not met)</span>
</li>
<li class="cc-pw-rule cc-pw-rule-max">
<svg class="cc-pw-icon" aria-hidden="true" focusable="false"><use class="cc-pw-yes" href="#cc-pw-check"/><use class="cc-pw-no" href="#cc-pw-xmark"/></svg>
No more than 20 characters<span class="cc-pw-met"> (met)</span><span class="cc-pw-unmet"> (not met)</span>
</li>
<!-- shown instead of the three above where field-sizing is missing -->
<li class="cc-pw-rule cc-pw-rule-range">
<svg class="cc-pw-icon" aria-hidden="true" focusable="false"><use class="cc-pw-yes" href="#cc-pw-check"/><use class="cc-pw-no" href="#cc-pw-xmark"/></svg>
8 to 20 characters<span class="cc-pw-met"> (met)</span><span class="cc-pw-unmet"> (not met)</span>
</li>
</ul>
</div>
</div>
<svg class="cc-pw-sprite" width="0" height="0" aria-hidden="true" focusable="false">
<symbol id="cc-pw-check" viewBox="0 0 512 512"><path fill="currentColor" d="M256 512a256 256 0 1 1 0-512 256 256 0 1 1 0 512zM374 145.7c-10.7-7.8-25.7-5.4-33.5 5.3L221.1 315.2 169 263.1c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l72 72c5 5 11.8 7.5 18.8 7s13.4-4.1 17.5-9.8L379.3 179.2c7.8-10.7 5.4-25.7-5.3-33.5z"/></symbol>
<symbol id="cc-pw-xmark" viewBox="0 0 512 512"><path fill="currentColor" d="M256 512a256 256 0 1 0 0-512 256 256 0 1 0 0 512zM167 167c9.4-9.4 24.6-9.4 33.9 0l55 55 55-55c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-55 55 55 55c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-55-55-55 55c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l55-55-55-55c-9.4-9.4-9.4-24.6 0-33.9z"/></symbol>
</svg>
</div>
CSS
/* decides whether the list sits beside or under the field;
a size container takes no width from its content, so give it one */
.cc-pw-wrap {
container: cc-pw-layout / inline-size;
width: 100%;
}
/* columns: inner padding, the typed text, the rest of the field, inner padding */
.cc-pw {
display: grid;
grid-template-columns: 1rem fit-content(calc(100% - 2rem)) 1fr 1rem;
grid-template-rows: auto 1fr;
width: min(17rem, 100%);
/* never so narrow that a 21st character could not widen the field */
min-width: calc(21ch + 2rem);
margin-inline: auto;
/* monospace: every masked character is 1ch wide */
font: 1rem/1.5 ui-monospace, Menlo, Consolas, "Liberation Mono", monospace;
}
/* the label is the visible box, so a click anywhere in it focuses the field */
.cc-pw-label {
grid-column: 1 / 5;
grid-row: 1;
padding: .55rem calc(1rem - 2px) 0;
background: #1c1c1e;
border: 2px solid #2a2a2d;
border-radius: 10px;
color: #88888f;
font: 600 .75rem/1.2 system-ui, sans-serif;
letter-spacing: .02em;
cursor: text;
transition: border-color .2s;
}
.cc-pw:focus-within .cc-pw-label {
outline: 2px solid #b8ff57;
outline-offset: 3px;
}
.cc-pw:has(.cc-pw-input:user-invalid) .cc-pw-label {
border-color: #ff6b6b;
}
.cc-pw:has(.cc-pw-input:user-valid) .cc-pw-label {
border-color: #57d9a3;
}
/* see-through text over the label, with no side padding or border,
so the field is exactly as wide as what has been typed */
.cc-pw-input {
grid-column: 2;
grid-row: 1;
z-index: 1;
width: auto;
min-width: 0;
max-width: 100%;
padding: 1.6rem 0 .5rem;
border: 0;
background: none;
color: #f0f0f0;
caret-color: #b8ff57;
font: inherit;
outline: none;
}
/* buttons drawn inside a password field add to its width */
.cc-pw-input::-ms-reveal {
display: none;
}
.cc-pw-input::-webkit-caps-lock-indicator {
display: none;
}
.cc-pw-input::-webkit-credentials-auto-fill-button {
display: none;
}
/* in the field's text column, so exactly as wide as the text */
.cc-pw-count {
grid-column: 2;
grid-row: 2;
margin-top: .9rem;
}
.cc-pw-rules {
display: grid;
gap: .55rem;
width: max-content;
margin: 0 0 0 -1rem;
padding: 0;
list-style: none;
color: #f0f0f0;
font: .875rem/1.35 system-ui, sans-serif;
}
/* --met and --unmet pick the icon and the hidden state word */
.cc-pw-rule {
--met: none;
--unmet: inline;
display: flex;
align-items: center;
gap: .55rem;
}
.cc-pw-rule-filled,
.cc-pw-rule-max {
--met: inline;
--unmet: none;
}
.cc-pw-rule-range {
display: none;
}
.cc-pw-icon {
flex: none;
width: 1.125rem;
height: 1.125rem;
}
.cc-pw-yes {
display: var(--met);
color: #57d9a3;
}
.cc-pw-no {
display: var(--unmet);
color: #88888f;
transition: color .2s;
}
.cc-pw-input:not(:placeholder-shown) ~ .cc-pw-count .cc-pw-no {
color: #ff6b6b;
}
/* a recommendation, not a rule */
.cc-pw-input:not(:placeholder-shown) ~ .cc-pw-count .cc-pw-rule-strong .cc-pw-no {
color: #ff9040;
}
.cc-pw-met {
display: var(--met);
}
.cc-pw-unmet {
display: var(--unmet);
}
.cc-pw-met,
.cc-pw-unmet {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.cc-pw-sprite {
position: absolute;
}
/* n characters are n ch wide, so each threshold is one query */
@container cc-pw-length (width > 7.5ch) {
.cc-pw-rule-min {
--met: inline;
--unmet: none;
}
}
@container cc-pw-length (width > 11.5ch) {
.cc-pw-rule-strong {
--met: inline;
--unmet: none;
}
}
@container cc-pw-length (width > 20.5ch) {
.cc-pw-rule-max {
--met: none;
--unmet: inline;
}
}
/* the fallback line reads validity */
.cc-pw-input:valid ~ .cc-pw-count .cc-pw-rule-range {
--met: inline;
--unmet: none;
}
/* while empty the field is as wide as its placeholder,
so every line is pinned to its empty state */
.cc-pw-input:placeholder-shown ~ .cc-pw-count .cc-pw-rule {
--met: none;
--unmet: inline;
}
.cc-pw-input:placeholder-shown ~ .cc-pw-count .cc-pw-rule-max {
--met: inline;
--unmet: none;
}
/* list beside the field once there is room for both */
@container cc-pw-layout (width >= 34rem) {
.cc-pw {
grid-template-columns: 1rem fit-content(15rem) 1fr 1rem 16rem;
width: 33rem;
}
.cc-pw-count {
grid-row: 1 / 3;
margin-top: 0;
}
.cc-pw-rules {
margin-left: 17.25rem;
padding: .15rem 0 .15rem 1.25rem;
border-left: 1px solid #2a2a2d;
}
}
/* the count signal, switched on only where it works */
@supports (field-sizing: content) {
.cc-pw-input {
field-sizing: content;
}
.cc-pw-count {
container: cc-pw-length / inline-size;
}
}
/* without it, validity cannot tell too short from too long,
so one line replaces the three that need the count,
and the field fills the box */
@supports not (field-sizing: content) {
.cc-pw-input,
.cc-pw-count {
grid-column: 2 / 4;
}
.cc-pw-rule-min,
.cc-pw-rule-strong,
.cc-pw-rule-max {
display: none;
}
.cc-pw-rule-range {
display: flex;
}
}
@media (prefers-reduced-motion: reduce) {
.cc-pw-label,
.cc-pw-no {
transition: none;
}
}
Understand it
How it works and 6 properties
CSS has no property for a character count and cannot read a form field's value. 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 only 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. Three more versions follow: a textarea showing three separate messages for empty, too short and long enough; a field where going over the limit is visible; and a requirements checklist beside the field.
How it works
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.
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.
<output> and an input event handler make a live counter in three lines. For a field with a hard limit such as a tweet or an SMS, a live count 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.
Accessibility notes
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, which links the description to the field in the accessibility tree.
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. 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 presents a list of errors before the reader has done 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. 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.
Mistakes worth avoiding
- Using
maxlengthwhere the overrun matters. Pasted text is truncated with no message, so the reader believes the whole thing went in. - Using
:invalidinstead of:user-invalidon 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
minlengthinvalidates 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.
Ship it
8 features in 4 browsers, 5 questions
Browser support
| Feature | ||||
|---|---|---|---|---|
form validation | 10 | 4 | 10.1 | 12 |
minlength attribute | 40 | 51 | 10.1 | 17 |
maxlength attribute | 4 | 4 | 5.1 | 12 |
pattern attribute | 10 | 4 | 10.1 | 12 |
:placeholder-shown | 47 | 51 | 9 | 79 |
field-sizing | 123 | 152 | 26.2 | 123 |
container queries | 106 | 110 | 16 | 106 |
:has() | 105 | 121 | 15.4 | 105 |
Figures come from Can I Use. The data set this table checks has no 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. The field-sizing row uses browser versions from MDN's compatibility data. Where field-sizing is missing, the three count-based requirement lines are replaced by a single fallback line that reads validity.
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
patternfor 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, which is the second variant.
- Search and filter inputs with a floor. A
minlengthof three stops a one letter query being submitted at all.
Frequently asked questions
Can you make a character counter with CSS alone?
field-sizing: content, where container queries test the width to print a number with one query per count. That only works for plain keyboard characters and only as far as the queries go. Any general-purpose counter requires JavaScript, typically an input event writing to an <output> element. The field's content is not available to CSS.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?
:user-invalid and not :invalid, since the first one also waits for the field to be left.How do I warn someone they are over a character limit?
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?
maxlength 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.