Most demos need a newer browser

They use :has() and container queries. Update your browser, or on an iPhone update iOS to 16.4 or later.

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 code

Up to 30 characters

A meter bar that fills as you type

See the code

Up to 20 characters

Over the limit in a form

See the code

Build 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 maxlength for emoji, CJK characters, or tabs. The count measures width in ch units. maxlength counts UTF-16 code units.

Ship it

18 features in 4 browsers, 3 questions

Browser support

Feature Chrome Firefox Safari Edge
field-sizing12315226.2123
container query units10511016105
container queries10611016106
@property8512816.485
tan() and atan2()11110815.4111
CSS counters423.112
color-mix()11111316.2111
clamp()797513.179
@supports2822912
:placeholder-shown4751979
:has()10512115.4105
:focus-within605210.179
:focus-visible86415.486
grid575210.116
flexbox21286.112
maxlength attribute445.112
pattern attribute10410.112
form validation10410.112

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?
No. A textarea wraps when the text reaches its width, so the width stops growing and the count based on it stalls at the wrap point. This technique only works for single-line fields.
Why does my CSS character count show the wrong number?
Most likely because the font is proportional, not monospace. The technique measures the field's width in 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?
Because dividing one length by another length in 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.