CSS Character Counter
Count characters in a text field with CSS alone. Uses field-sizing, container queries, and a monospace font to display a live count with no JavaScript.
- Level
- Advanced
- Published
- Updated
- Reading time
- 5 min
Try it
4 live demos
Your browser can't run this demo
It needs :has(), color-mix(), trigonometric functions and field-sizing, which this browser doesn't support yet. It works in Chrome 123+, Edge 123+, Firefox 152+ and Safari 26.2+.
Needs :has(), color-mix(), trigonometric functions and field-sizing
Up to 30 characters
Type, paste or delete in the headline
Other ways to build it
Up to 24 characters
Characters left, in words
See the codeUp to 30 characters
A meter bar that fills as you type
See the codeOver the limit in a form
See the codeBuild it
8 snippets to copy
CSS Character Counter
HTML
<div class="ctr-demo">
<div class="ctr">
<label class="ctr-box" for="headline">Headline</label>
<input class="ctr-input" id="headline" type="text" maxlength="30"
placeholder="Write a headline" autocomplete="off"
aria-describedby="headline-limit">
<div class="ctr-len"><span class="ctr-count"></span></div>
<p class="ctr-limit" id="headline-limit">Up to 30 characters</p>
</div>
</div>
CSS
/* the count as a whole number: registered, so the division below rounds */
@property --ctr-n {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
/* a size container takes no width from its content, so it is given one */
.ctr-demo {
container: ctr-demo / inline-size;
display: grid;
justify-items: center;
width: 100%;
}
/* columns: inner padding, the text, the rest of the row, inner padding.
In a monospace font one character is 1ch wide, so the row has room
for exactly 30, the field's maxlength */
.ctr {
display: grid;
grid-template-columns: 1em auto 1fr 1em;
width: calc(30ch + 2em);
font: clamp(.8125rem, 4.6cqi, 1.25rem)/1.5 ui-monospace, Menlo, Consolas, "Liberation Mono", monospace;
}
/* the label is the visible box, so a click anywhere in it focuses the field */
.ctr-box {
grid-column: 1 / -1;
grid-row: 1;
padding: 1em calc(1.3333em - 2px) 0;
border: 2px solid #c9c9d0;
border-radius: 10px;
background: #f4f4f5;
color: #18181b;
font: 600 .75em/1.2 system-ui, sans-serif;
letter-spacing: .02em;
cursor: text;
}
.ctr:focus-within .ctr-box {
border-color: #6f2fd6;
}
.ctr:has(.ctr-input:focus-visible) .ctr-box {
outline: 2px solid #6f2fd6;
outline-offset: 3px;
}
/* no padding, border or background at the sides, so the field's
width is the typed text's width and nothing else */
.ctr-input {
grid-column: 2 / 4;
grid-row: 1;
z-index: 1;
min-width: 0;
padding: 2.15em 0 .75em;
border: 0;
background: none;
color: #18181b;
caret-color: #6f2fd6;
font: inherit;
letter-spacing: 0;
outline: none;
}
.ctr-input::placeholder {
color: #5f5f68;
opacity: 1;
}
/* in the text's own column, so exactly as wide as the text */
.ctr-len {
grid-column: 2 / 4;
grid-row: 1;
z-index: 1;
padding-top: calc(.7em + 2px);
pointer-events: none;
}
/* 30ch wide whatever the text's width, so the count ends at the right padding */
.ctr-count {
display: flex;
justify-content: flex-end;
width: 30ch;
}
/* without field-sizing the field keeps one width, so only the limit shows.
The state color is the pill's, so the number always reads clearly */
.ctr-count::before {
--ctr-tone: #5f5f68;
content: "Max 30";
padding: .15em .6em;
border-radius: 999px;
background: color-mix(in srgb, var(--ctr-tone) 22%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ctr-tone) 60%, transparent);
color: #18181b;
font: 500 .75em/1.2 ui-monospace, Menlo, Consolas, "Liberation Mono", monospace;
}
.ctr-limit {
grid-column: 1 / -1;
margin: .55em 0 0;
padding-inline: 1.3333em;
color: #5f5f68;
font: .75em/1.4 system-ui, sans-serif;
}
@supports (field-sizing: content) {
.ctr-input {
grid-column: 2;
field-sizing: content;
}
.ctr-len {
grid-column: 2;
container: ctr-len / inline-size;
}
/* 100cqi is the text's width and 1ch one character. tan(atan2(a, b))
is a / b as a plain number, and <integer> rounds it: the count */
.ctr-count {
--ctr-n: tan(atan2(100cqi, 1ch));
counter-reset: ctr-n var(--ctr-n);
}
/* an empty field is as wide as its placeholder, so 0 is pinned */
.ctr-input:placeholder-shown ~ .ctr-len .ctr-count {
--ctr-n: 0;
}
/* clamp(0, n - 24, 1) is 0 up to 24 and 1 from 25: orange with
five or fewer left, coral at 30 */
.ctr-count::before {
--ctr-tone: color-mix(in srgb, #b62430 calc(clamp(0, var(--ctr-n) - 29, 1) * 100%), color-mix(in srgb, #9a4a00 calc(clamp(0, var(--ctr-n) - 24, 1) * 100%), #5f5f68));
content: counter(ctr-n) " / 30";
}
}
Characters left, in words
Instead of a number, write out the state in words. "24 characters left" counts down, and at 1 it switches to the singular "1 character left", which a number-only counter cannot do. The field has room for exactly 24 characters, so the rest of the row is the space left. One container query per count, each printing its own sentence. The pill is coral at none left and orange from 1 to 5 left. Pick this when the wording matters more than the number, or the count runs down to a limit.
HTML
<div class="ctr-demo">
<div class="ctr ctrl">
<label class="ctr-box" for="display-name">Display name</label>
<input class="ctr-input" id="display-name" type="text" maxlength="24"
autocomplete="off" aria-describedby="display-name-limit">
<p class="ctrl-limit" id="display-name-limit">Up to 24 characters</p>
<div class="ctrl-rest"><span class="ctrl-count"></span></div>
</div>
</div>
CSS
/* builds on the main example: .ctr-demo, .ctr, .ctr-box and
.ctr-input are its rules, unchanged */
/* 24 characters of room, so the rest of the row is the space left */
.ctrl {
width: calc(24ch + 2em);
}
/* the static limit sits on the label's line, outside the label */
.ctrl-limit {
grid-column: 2 / 4;
grid-row: 1;
z-index: 1;
align-self: start;
justify-self: end;
margin: 0;
padding-top: calc(1em + 2px);
color: #5f5f68;
font: .75em/1.2 system-ui, sans-serif;
pointer-events: none;
}
/* only shown where the field's width follows its text */
.ctrl-rest {
display: none;
grid-column: 3;
grid-row: 2;
margin-top: .55em;
}
/* right-aligned on the inner edge; a sentence wider than the
space left runs out to the left */
.ctrl-count {
display: flex;
justify-content: flex-end;
}
/* the same pill as the main count */
.ctrl-count::before {
--ctr-tone: #b62430;
content: "No characters left";
padding: .15em .6em;
border-radius: 999px;
background: color-mix(in srgb, var(--ctr-tone) 22%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ctr-tone) 60%, transparent);
color: #18181b;
font: 500 .75em/1.2 ui-monospace, Menlo, Consolas, "Liberation Mono", monospace;
white-space: nowrap;
}
@supports (field-sizing: content) {
.ctrl-rest {
display: block;
container: ctrl-left / inline-size;
}
}
/* the container is the rest of the row: k characters left is k ch,
so one query per count, each free to say anything */
@container ctrl-left (width > 0.5ch) { .ctrl-count::before { content: "1 character left"; } }
@container ctrl-left (width > 1.5ch) { .ctrl-count::before { content: "2 characters left"; } }
@container ctrl-left (width > 2.5ch) { .ctrl-count::before { content: "3 characters left"; } }
@container ctrl-left (width > 3.5ch) { .ctrl-count::before { content: "4 characters left"; } }
@container ctrl-left (width > 4.5ch) { .ctrl-count::before { content: "5 characters left"; } }
@container ctrl-left (width > 5.5ch) { .ctrl-count::before { content: "6 characters left"; } }
@container ctrl-left (width > 6.5ch) { .ctrl-count::before { content: "7 characters left"; } }
@container ctrl-left (width > 7.5ch) { .ctrl-count::before { content: "8 characters left"; } }
@container ctrl-left (width > 8.5ch) { .ctrl-count::before { content: "9 characters left"; } }
@container ctrl-left (width > 9.5ch) { .ctrl-count::before { content: "10 characters left"; } }
@container ctrl-left (width > 10.5ch) { .ctrl-count::before { content: "11 characters left"; } }
@container ctrl-left (width > 11.5ch) { .ctrl-count::before { content: "12 characters left"; } }
@container ctrl-left (width > 12.5ch) { .ctrl-count::before { content: "13 characters left"; } }
@container ctrl-left (width > 13.5ch) { .ctrl-count::before { content: "14 characters left"; } }
@container ctrl-left (width > 14.5ch) { .ctrl-count::before { content: "15 characters left"; } }
@container ctrl-left (width > 15.5ch) { .ctrl-count::before { content: "16 characters left"; } }
@container ctrl-left (width > 16.5ch) { .ctrl-count::before { content: "17 characters left"; } }
@container ctrl-left (width > 17.5ch) { .ctrl-count::before { content: "18 characters left"; } }
@container ctrl-left (width > 18.5ch) { .ctrl-count::before { content: "19 characters left"; } }
@container ctrl-left (width > 19.5ch) { .ctrl-count::before { content: "20 characters left"; } }
@container ctrl-left (width > 20.5ch) { .ctrl-count::before { content: "21 characters left"; } }
@container ctrl-left (width > 21.5ch) { .ctrl-count::before { content: "22 characters left"; } }
@container ctrl-left (width > 22.5ch) { .ctrl-count::before { content: "23 characters left"; } }
@container ctrl-left (width > 23.5ch) { .ctrl-count::before { content: "24 characters left"; } }
/* five or fewer left */
@container ctrl-left (width > 0.5ch) { .ctrl-count::before { --ctr-tone: #9a4a00; } }
@container ctrl-left (width > 5.5ch) { .ctrl-count::before { --ctr-tone: #5f5f68; } }
A meter bar that fills as you type
No number at all, only a bar along the bottom of the field that fills with the text. The rail is 30ch wide with a tick every five characters. The fill shares the text's grid column, so its width tracks the typed text. The count is used only to pick the fill's color: teal, orange from 25, coral at 30. Pick this for a quick sense of how much room is left, without reading a number.
HTML
<div class="ctr-demo">
<div class="ctr ctrm">
<label class="ctr-box" for="tagline">Tagline</label>
<input class="ctr-input" id="tagline" type="text" maxlength="30"
autocomplete="off" aria-describedby="tagline-limit">
<div class="ctrm-rail" aria-hidden="true"></div>
<div class="ctrm-len" aria-hidden="true"><div class="ctrm-fill"></div></div>
<p class="ctr-limit" id="tagline-limit">Up to 30 characters</p>
</div>
</div>
CSS
/* builds on the main example: .ctr-demo, .ctr, .ctr-box, .ctr-input,
.ctr-limit and @property --ctr-n are its rules, unchanged */
/* room under the text for the bar */
.ctrm .ctr-input {
padding-bottom: 1.4em;
}
/* the rail spans the text's room; the fill sits in the text's own
column, so it is exactly as wide as the text, with no number */
.ctrm-rail,
.ctrm-len {
display: none;
grid-row: 1;
z-index: 1;
align-self: end;
height: .3125em;
margin-bottom: .7em;
border-radius: 999px;
pointer-events: none;
}
/* a tick every five characters */
.ctrm-rail {
grid-column: 2 / 4;
background: repeating-linear-gradient(90deg, transparent 0 calc(5ch - 2px), #5f5f68 calc(5ch - 2px) 5ch), #c9c9d0;
}
.ctrm-fill {
height: 100%;
border-radius: inherit;
background: #006c4a;
}
@supports (field-sizing: content) {
.ctrm-rail,
.ctrm-len {
display: block;
}
.ctrm-len {
grid-column: 2;
container: ctrm-len / inline-size;
}
/* the count only picks the fill's color: orange from 25, coral at 30 */
.ctrm-fill {
--ctr-n: tan(atan2(100cqi, 1ch));
background: color-mix(in srgb, #b62430 calc(clamp(0, var(--ctr-n) - 29, 1) * 100%), color-mix(in srgb, #9a4a00 calc(clamp(0, var(--ctr-n) - 24, 1) * 100%), #006c4a));
}
}
Over the limit in a form
The limit is soft: pattern=.{0,20} sets it at 20 while maxlength=30 stops the field at 30, so characters 21 to 30 go in, are visible, and the field never outgrows its row. The count reads "n / 20" and keeps counting to 30. The pill is gray to 15, orange from 16 to 20, coral from 21. From 21 the field is :invalid, the box border turns coral, and the Save button's fill turns gray with cursor: not-allowed. Pick this for a limit people may need to see past while editing, with the browser refusing the submit until it fits.
HTML
<!-- method="dialog" keeps this demo on its page; give yours an action -->
<form class="ctr-demo ctrf" method="dialog">
<div class="ctr">
<label class="ctr-box" for="event-name">Event name</label>
<!-- pattern is the limit, maxlength the hard stop -->
<input class="ctr-input" id="event-name" name="event" type="text"
pattern=".{0,20}" maxlength="30" autocomplete="off"
aria-describedby="event-name-limit">
<div class="ctr-len"><span class="ctr-count"></span></div>
<p class="ctr-limit" id="event-name-limit">Up to 20 characters</p>
</div>
<button class="ctrf-save" type="submit">Save</button>
</form>
CSS
/* builds on the main example: every .ctr rule and @property --ctr-n
are its rules; this sets a lower limit and shows the overrun */
/* pattern sets the limit at 20, maxlength stops the field at 30, so
the overrun is let in, can be seen, and never outgrows the row */
.ctrf {
gap: 1.25rem;
}
.ctrf .ctr-count::before {
content: "Max 20";
}
.ctrf:has(.ctr-input:invalid) .ctr-box {
border-color: #b62430;
}
.ctrf-save {
padding: .65rem 1.4rem;
border: 0;
border-radius: 999px;
background: #6f2fd6;
color: #fff;
font: 600 .9375rem/1.2 system-ui, sans-serif;
cursor: pointer;
}
.ctrf-save:focus-visible {
outline: 2px solid #6f2fd6;
outline-offset: 3px;
}
/* the form will not submit while the field is invalid, so the button says so;
its label keeps full contrast, since it can still be pressed */
.ctrf:has(.ctr-input:invalid) .ctrf-save {
background: #c9c9d0;
color: #18181b;
cursor: not-allowed;
}
@supports (field-sizing: content) {
.ctrf .ctr-count::before {
--ctr-tone: color-mix(in srgb, #b62430 calc(clamp(0, var(--ctr-n) - 20, 1) * 100%), color-mix(in srgb, #9a4a00 calc(clamp(0, var(--ctr-n) - 15, 1) * 100%), #5f5f68));
content: counter(ctr-n) " / 20";
}
/* the field is exactly as wide as its text, so from 20ch on its
background sits under the characters past the limit */
.ctrf .ctr-input {
background: linear-gradient(90deg, transparent 20ch, rgba(182, 36, 48, .12) 20ch) content-box;
}
}
Understand it
How it works and 18 properties
This page shows a CSS character counter that displays a live count next to a text field, updating as you type with no JavaScript. The field uses a monospace font where each character measures 1ch wide, so the field's width in container query units shows how many characters have been typed. Wide characters like emoji and Chinese or Japanese text are wider than 1ch and make the count read high.
Readers looking to style an input based on minlength and maxlength, rather than count the characters, will find that on the Character Count Indicator page.
How it works
With field-sizing: content, the input's width adjusts to match its text, and the input has no padding or border at its sides so nothing else contributes to that width. A sibling in the text's own auto grid column becomes a size container, so that sibling's width also matches the typed content. Inside it, 100cqi is the text's width and 1ch is one character. tan(atan2(100cqi, 1ch)) equals that ratio as a number, stored in --ctr-n and registered as <integer>, which rounds it to a whole number. counter-reset: ctr-n var(--ctr-n) and content: counter(ctr-n) print it.
The main demo divides with tan(atan2()) rather than writing one container query per count because WebKit gets ch thresholds wrong in container queries when the CSS zoom property is anything other than 1. Thresholds in px or rem would read correctly in WebKit under CSS zoom, but they tie the count to one font's character width. DM Mono measures 0.6em per character while Consolas measures 0.5498em.
The empty field would count its placeholder's width. The placeholder is 16 characters, and without a fix the pill would say 16 / 30 before anything is typed. .ctr-input:placeholder-shown ~ .ctr-len .ctr-count { --ctr-n: 0; } pins it to 0 while the placeholder is visible, so the empty count is right.
The pill changes color as the count approaches the limit. Gray to 24, orange from 25 to 29, coral at 30. That state is set with clamp() inside a color-mix(): clamp(0, var(--ctr-n) - 24, 1) is 0 up to 24 characters and 1 from 25 on, so multiplying it by 100% gives the orange mix percentage. The coral step works the same way at 30. No container query is involved, so the state cannot drift from the count.
Accessibility notes
The count is generated content and is not tied to the field. It updates visually as you type. The static limit line is a real paragraph with aria-describedby tying it to the field, so Chromium's accessibility tree reports it as the textbox's description. Where the count matters for accessibility, state it in a real element tied to the field, not in generated content.
Tab reaches the field, and the box shows a focus ring. The count and its container have pointer-events: none, so a click on the count lands on the label and focuses the field.
Mistakes worth avoiding
- Using a proportional font. Every character must be exactly 1ch wide for the count to be accurate, which only works in a monospace font.
- Adding side padding or a border to the input. The field's width has to be the text's width and nothing else, so put the padding and border on the label box.
- Expecting the count to match
maxlengthfor emoji, CJK characters, or tabs. The count measures width inchunits.maxlengthcounts UTF-16 code units.
Ship it
18 features in 4 browsers, 3 questions
Browser support
| Feature | ||||
|---|---|---|---|---|
field-sizing | 123 | 152 | 26.2 | 123 |
container query units | 105 | 110 | 16 | 105 |
container queries | 106 | 110 | 16 | 106 |
@property | 85 | 128 | 16.4 | 85 |
tan() and atan2() | 111 | 108 | 15.4 | 111 |
CSS counters | 4 | 2 | 3.1 | 12 |
color-mix() | 111 | 113 | 16.2 | 111 |
clamp() | 79 | 75 | 13.1 | 79 |
@supports | 28 | 22 | 9 | 12 |
:placeholder-shown | 47 | 51 | 9 | 79 |
:has() | 105 | 121 | 15.4 | 105 |
:focus-within | 60 | 52 | 10.1 | 79 |
:focus-visible | 86 | 4 | 15.4 | 86 |
grid | 57 | 52 | 10.1 | 16 |
flexbox | 21 | 28 | 6.1 | 12 |
maxlength attribute | 4 | 4 | 5.1 | 12 |
pattern attribute | 10 | 4 | 10.1 | 12 |
form validation | 10 | 4 | 10.1 | 12 |
The figures come from the Can I Use features named in each row, except field-sizing, @property, tan() and atan2(), and color-mix(), which Can I Use does not track. Those four rows are unlinked.
What you can build with it
- Headline and title fields. A live count for a character limit, where the field is single-line and monospace is acceptable.
- Display name and username inputs. Limits that people need to see as they type, with the count updating in real time.
- Teaching container queries. A working example of container query units reading an element's width and using it in a calculation.
Frequently asked questions
Can CSS count characters in a textarea?
Why does my CSS character count show the wrong number?
ch units, and that only equals the character count when every character is exactly 1ch wide. Wide characters such as emoji, CJK, and tabs also throw it off even in a monospace font.Why use tan and atan2 instead of dividing in calc?
calc() is not supported everywhere yet. tan(atan2(100cqi, 1ch)) equals 100cqi / 1ch as a plain number, and every engine that knows those trig functions can compute it. Where length-by-length division is supported, you can write calc(100cqi / 1ch) directly.