GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Textarea Character Counter: 9 Grapheme-Aware UIs

A character count looks like one line of length, until one emoji counts as 2 and the number flickers mid-composition — scenes 01 and 04 close those two gaps.

Type into All Nine Textarea Character Counters on This Page

Each grid cell below types its own demo sentence as soon as you scroll to it, and the moment you click the field, press a key or paste, the demo stops and your text runs through the same counting code. Nothing is sent to a server, and the demo sentences exist only so the numbers have something to count.

Auto-plays · click a tile to jump to its section · all nine in one zip

Textarea Character Counter: 9 Patterns Where Grapheme Counting Fixes the Number

The nine are ordered by where the plain number goes wrong. Item 01 is the base case where an emoji counts as one, 02 turns that count into a ring against a limit, and 03 handles full-width and half-width text that should not weigh the same. Item 04 stops the number from flickering during composition, 05 paints only the overflow, 06 handles a paste that blows past the limit, 07 measures characters, words, bytes and reading time together, 08 changes the hint at each threshold, and 09 keeps a screen reader from reading every keystroke. If you are adding a counter to a writing form for the first time, 01, 02 and 08 together give you a number, a ring and a hint inside one field.

Most guides on how to build an html textarea character counter stop at value.length, which is why the code below counts what a person sees instead.

Code for Each Counting Technique

Every item is one textarea, its label, and an aria-describedby that points at the count element, and the snippets below show only the part that does the counting. The remaining markup and styles are inside the zip under each demo, and the demo sentences never leave the page. Clicking the field or typing stops the demo, and from then on your own text goes through the same calculation.

01Live Count in Grapheme Clusters Instead of Code Units

graphemes splits the value with Intl.Segmenter into the characters a person sees and shows that as the big number, while the small "length says" number beside it is ta.value.length, so the two split apart the moment an emoji arrives. At poster time the cluster count is 12 and length reads 19, which is the gap this whole set is about. Item 01 covered 13.4% of the frame at an average intensity of 64.8 across 23/23 measured frames.

Live Count by Grapheme Cluster
Open in a new tab

01 · Live Count by Grapheme Cluster — Measured area 13.419% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

STRIP_SLOTSPOP_MSCOUNT_LERP_RATEANNOUNCE_EVERY
  function update(nowMs) {
    var list = graphemes(ta.value);
    var count = list.length;
    var view = count <= STRIP_SLOTS ? list : list.slice(count - STRIP_SLOTS);
    for (var k = 0; k < STRIP_SLOTS; k += 1) {
      var g = view[k] || '';
      tiles[k].textContent = g;
      tiles[k].className = 'cc__tile' + (g ? ' is-on' : '') + (g.length > 1 ? ' is-multi' : '');
    }
    if (count > lastCount && !reduce) born[Math.min(count, STRIP_SLOTS) - 1] = nowMs;
    if (count % ANNOUNCE_EVERY === 0 && count !== lastCount && count > 0) {
      sr.textContent = 'Now ' + count + ' chars';
    }
    lastCount = count;
    target = count;
    lenTarget = ta.value.length;
    if (reduce) {
      shown = target;
      lenShown = lenTarget;
      paintNumbers();
    }
  }

02Spring Ring That Fills as the Limit Gets Closer

The used share of the limit becomes the arc length of a ring, and that value follows its target through the RING_SPRING_STIFFNESS and RING_SPRING_DAMPING spring while the colour shifts from mint to amber at 80% and to pink at the limit. When the text first passes the limit the ring shakes with a decaying sine, so use it when the limit should catch the eye before the number does. Item 02 covered 30.3% of the frame at an average intensity of 108.5 across 23/23 measured frames.

Spring Ring That Fills with the Characters Used
Open in a new tab

02 · Spring Ring That Fills with the Characters Used — Measured area 30.287% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

CHAR_LIMITRING_SPRING_STIFFNESSRING_SPRING_DAMPINGWARN_RATIOSHAKE_AMP
  function paint(nowMs) {
    var ratio = used / CHAR_LIMIT;
    var drawn = clamp(x, 0, 1);
    arc.style.strokeDasharray = (drawn * RING_LENGTH).toFixed(2) + ' ' + RING_LENGTH.toFixed(2);
    arc.style.stroke = colorAt(Math.min(x, 1.12));
    card.style.background = tintAt(Math.min(x, 1.12), 0.16);
    var remain = CHAR_LIMIT - used;
    countEl.textContent = String(remain);
    countEl.style.color = remain < 0 ? 'rgb(255,158,187)' : '';
    hint.textContent = remain >= 0 ? 'Left: ' + remain + ' chars' : 'Over by ' + (-remain) + ' chars';
    var pulse = 1 + Math.max(0, x - 1) * 0.5;
    var dx = 0;
    if (shakeAt >= 0 && !reduce) {
      var age = nowMs - shakeAt;
      var amp = SHAKE_AMP * Math.exp(-age / SHAKE_DECAY_MS);
      dx = amp < 0.1 ? 0 : amp * Math.sin(age * SHAKE_OMEGA);
    }
    ring.style.transform = 'translateX(' + dx.toFixed(2) + 'px) scale(' + pulse.toFixed(3) + ')';
    return ratio;
  }

03Weighted Count Where Full-Width Costs 2 and Half-Width Costs 1

weightOf returns HALF_WEIGHT (1) for a first code point at or below 0x7e or in the half-width katakana range and FULL_WEIGHT (2) for everything else, so each cluster is weighed by its first code point, and the sum of those weights fills a bar up to a width limit of 80. It fits a field mixing Japanese full-width text with Latin half-width text, or any limit set by display width instead of character count. The demo sentence mixes Japanese kana with Latin and half-width katakana and is the same on every language version of this page, and item 03 covered 10.0% of the frame (9.97% before rounding) at an average intensity of 150.6 across 23/23 measured frames.

Width-Weighted Count: Full-Width 2, Half-Width 1
Open in a new tab

03 · Width-Weighted Count: Full-Width 2, Half-Width 1 — Measured area 9.971% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

WIDTH_LIMITHALF_WEIGHTFULL_WEIGHTPOP_MS
  function graphemes(s) {
    if (!seg) return Array.from(s);
    return Array.from(seg.segment(s), function (x) { return x.segment; });
  }
  function weightOf(g) {
    var cp = g.codePointAt(0);
    if (cp <= 0x7e || (cp >= 0xff61 && cp <= 0xff9f)) return HALF_WEIGHT;
    return FULL_WEIGHT;
  }

04IME-Safe Counter That Holds Its Number During Composition

When compositionstart sets composing, onInput updates only the "count now" number and holds the "count after commit" number, and compositionend raises the committed value once and bumps it only if it changed. That stops the figure from rising and falling in forms where people type Korean or Japanese; counting an emoji as one is item 01's job, and 04 only holds the number steady while the composition is open. The demo feeds the steps an input method produces, such as n, に, にほ and にほん, through the same handlers, so it is a replay and not a real IME scene, and item 04 covered 1.9% of the frame at an average intensity of 121.1 across 23/23 measured frames.

IME-Safe Counter That Ignores Mid-Composition Flicker
Open in a new tab

04 · IME-Safe Counter That Ignores Mid-Composition Flicker — Measured area 1.882% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

STEP_MSPAUSE_MSBUMP_MSHOLD_MS
  function onCompStart() { composing = true; }
  function onInput() {
    rawCount = countGraphemes(ta.value);
    if (!composing) safeCount = rawCount;
  }
  function onCompEnd(nowMs) {
    composing = false;
    preEl.textContent = '';
    var next = countGraphemes(ta.value);
    if (next !== safeCount) bumpAt = nowMs;
    safeCount = next;
    rawCount = next;
    sr.textContent = 'Now ' + safeCount + ' chars';
  }

05Mirror Layer That Highlights Only the Overflowing Text

A mirror layer sits behind the field with the same font, line height, padding and white-space: pre-wrap, and only the characters past the limit are wrapped in mark to draw a highlight while the scroll position stays synced. The field background is transparent and the mirror text is transparent too, so only the painted colour shows through, and a fresh overflow starts dark and settles so people can see exactly where to cut. Item 05 covered 14.4% of the frame at an average intensity of 117.8 across 22/23 measured frames.

Mirror Layer That Marks Only the Overflow
Open in a new tab

05 · Mirror Layer That Marks Only the Overflow — Measured area 14.367% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

MIRROR_LIMITFLASH_MSCHUNK_MS
  function update(nowMs) {
    var list = graphemes(ta.value);
    var over = Math.max(0, list.length - MIRROR_LIMIT);
    mirror.textContent = '';
    mirror.appendChild(document.createTextNode(list.slice(0, MIRROR_LIMIT).join('')));
    mark = null;
    if (over > 0) {
      mark = document.createElement('mark');
      mark.className = 'cc__mark';
      mark.textContent = list.slice(MIRROR_LIMIT).join('');
      mirror.appendChild(mark);
    }
    mirror.appendChild(document.createTextNode('​'));
    syncScroll();
    countEl.textContent = String(list.length);
    countEl.style.color = over > 0 ? '#9a2b52' : '';
    overEl.textContent = over > 0 ? 'Over ' + over + ' chars' : '';
    overEl.className = 'cc__over' + (over > 0 ? ' is-on' : '');
    if (over > lastOver && !reduce) flashAt = nowMs;
    lastOver = over;
    var next = over > 0 ? 1 : 0;
    if (next !== stage) {
      sr.textContent = next === 1 ? 'Over the limit' : '';
      stage = next;
    }

06Paste Clip That Trims the Excess and Signals With a Damped Shake

The paste event is caught with preventDefault, the free room room is computed, only the front of the pasted text is kept, and a damped sine shakes the field while a "trimmed N" toast rises. A selected range is replaced by the pasted text, so pasting in the middle still counts right, and for forms that receive long text it is better to say something was cut than to cut silently. Item 06 covered 12.0% of the frame at an average intensity of 110.3 across 22/23 measured frames.

Clip an Overflowing Paste and Signal It with a Damped Shake
Open in a new tab

06 · Clip an Overflowing Paste and Signal It with a Damped Shake — Measured area 12.012% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

PASTE_LIMITSHAKE_AMPSHAKE_DECAY_MSSHAKE_OMEGATOAST_MS
  function applyPaste(text, nowMs) {
    var start = ta.selectionStart;
    var end = ta.selectionEnd;
    var before = ta.value.slice(0, start);
    var after = ta.value.slice(end);
    var room = PASTE_LIMIT - graphemes(before + after).length;
    var incoming = graphemes(text);
    var take = incoming.slice(0, Math.max(0, room)).join('');
    ta.value = before + take + after;
    ta.setSelectionRange(start + take.length, start + take.length);
    refresh();
    if (incoming.length > room) signal(incoming.length - Math.max(0, room), nowMs);
  }

07Meter That Reads Characters, Words, Bytes and Reading Time Together

From one text it measures characters as grapheme clusters, words as isWordLike pieces, bytes as the UTF-8 length from TextEncoder, and reading time as the character count divided by READ_CHARS_PER_MIN, rounded up to seconds. Put it in the footer of a writing tool and all four numbers roll in one glance, with tile backgrounds and underline bars filling against each cap. Item 07 covered 23.0% of the frame at an average intensity of 127.8 across 23/23 measured frames.

Meter for Characters, Words, Bytes and Reading Time
Open in a new tab

07 · Meter for Characters, Words, Bytes and Reading Time — Measured area 22.963% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

READ_CHARS_PER_MINNUMBER_LERP_RATEANNOUNCE_EVERYBAR_CAPS
  function measure(text) {
    var chars = gSeg ? Array.from(gSeg.segment(text)).length : Array.from(text).length;
    var words = wSeg
      ? Array.from(wSeg.segment(text)).filter(function (x) { return x.isWordLike; }).length
      : text.split(/\s+/).filter(Boolean).length;
    var bytes = enc.encode(text).length;
    var secs = Math.ceil(chars / READ_CHARS_PER_MIN * 60);
    return [chars, words, bytes, secs];
  }

08Remaining-Count Hint Whose Message and Colour Follow the Thresholds

Past WARN_RATIO the colour moves linearly from mint to amber, past the limit it moves to pink within OVER_SPAN, and the remaining number grows from 1 to MAX_SCALE (1.8). The message fades only when the band changes, so it does not tremble on every keystroke, which suits an input with a strict limit where the remaining count must be the first thing read. Item 08 covered 20.2% of the frame at an average intensity of 103.7 across 23/23 measured frames.

Remaining-Count Hint with Threshold Stages and Color Interpolation
Open in a new tab

08 · Remaining-Count Hint with Threshold Stages and Color Interpolation — Measured area 20.176% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

HINT_LIMITWARN_RATIOMAX_SCALEOVER_SPANMSG_FADE_MS
  function update(nowMs) {
    used = countGraphemes(ta.value);
    var ratio = used / HINT_LIMIT;
    var next = ratio >= 1 ? 2 : (ratio >= WARN_RATIO ? 1 : 0);
    if (next !== stage) {
      stage = next;
      var text = next === 2 ? 'Over the limit' : (next === 1 ? 'Close to the limit' : 'Plenty of room');
      msgEl.textContent = text;
      sr.textContent = next === 0 ? '' : text;
      swapAt = nowMs;
    }
    if (reduce) { shownUsed = used; paint(nowMs); }
  }

09Throttled aria-live Announcements and Reduced Motion

Reading every change aloud gets in the way of typing, so the sr text changes only the first time the ratio crosses 0.8 or 1 from ANNOUNCE_RATIOS, and when the "Reduce motion" button or the system setting is on, every interpolation becomes an immediate value. It is the baseline for any form that screen reader users will fill in, and the "announce every time" count in the demo exists only to show the contrast; the real aria-live region receives text only when a threshold is entered. Item 09 covered 4.7% of the frame at an average intensity of 138.0 across 23/23 measured frames.

aria-live Throttle and Reduced Motion for Counters
Open in a new tab

09 · aria-live Throttle and Reduced Motion for Counters — Measured area 4.685% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ANNOUNCE_LIMITANNOUNCE_RATIOSTICK_SLOTSNUMBER_LERP_RATEPOP_MS
    if (len !== lastLen) {
      rawCount += 1;
      addTick(rawList, rawCount, nowMs);
    }
    var ratio = len / ANNOUNCE_LIMIT;
    for (var i = 0; i < ANNOUNCE_RATIOS.length; i += 1) {
      if (!passed[i] && ratio >= ANNOUNCE_RATIOS[i]) {
        passed[i] = true;
        safeCount += 1;
        addTick(safeList, safeCount, nowMs);
        sr.textContent = i === 1 ? 'Over the limit' : 'Close to the limit';
      }
    }
    lastLen = len;

Where the Values Change the Scene — Limit, Ring Spring, Warning Ratio

How fast the ring fills and when the colour turns is set by the numbers in this table and by three constants, not by how many items you copy.

Item Cumulative area % Average intensity Moving frames
01 Grapheme count 13.4% 64.8 23/23
02 Spring ring 30.3% 108.5 23/23
03 Weighted count 10.0% 150.6 23/23
04 IME-safe 1.9% 121.1 23/23
05 Mirror layer 14.4% 117.8 22/23
06 Paste clip 12.0% 110.3 22/23
07 Meter 23.0% 127.8 23/23
08 Hint text 20.2% 103.7 23/23
09 Announce throttle 4.7% 138.0 23/23

Items 03, 04 and 09 sit under 10% (03 is 9.97%, shown as 10.0% in the table), which is normal for a component set: 04 is two numbers and a dot, yet it changes at every composition step and moves in all 23 frames, and 09 only adds a few tick marks. The poster is a typing scene, so the shake in 02 and the number bump in 04 show up in the moving demo and not in the still frame. CHAR_LIMIT sets the character count at which the ring fills and shakes, RING_SPRING_STIFFNESS sets how fast the ring chases its target, and WARN_RATIO sets where amber begins; for 02 the damping ratio is 14 divided by 2√220, about 0.47, which gives an overshoot of about 19% on a step response.

Where It Breaks — Emoji, Mid-Composition Input, Paste Floods

The fallback bites first. In 01, graphemes falls back to Array.from when Intl.Segmenter is missing, and that splits by code point, so the family emoji breaks back into three people and two joiners, the flag becomes two, and cafe with a combining accent becomes five, which erases the main scene of this set. In 03, weightOf looks only at the first code point of a cluster and gives width 2 to anything above 0x7e, so Korean, accented Latin and emoji all count as 2 as well, and a service with a width-based limit has to move its own width rule into that function.

Item 06 has two separate paths, paste and input. A paste places the caret at the end of the pasted text with setSelectionRange, but the input path, which catches overflow that arrives by typing, only reassigns value cut to 80 clusters and has no line that puts the caret back, so the caret position there is up to the browser. The mirror in 05 needs the same font, padding and line height as the field and hides the field scrollbar to match widths, so changing only the field's font or padding shifts the painted part. The strip in 01 draws at most STRIP_SLOTS (16) slots and then shows only the last 16, and to open all nine sources with these limits intact, enter the password jg6vwkst when you unzip the download.

Three Variants and Accessibility

Item Constant Value What changes
02 CHAR_LIMIT 120 → 60 Cutting the limit from 120 to 60 fills the ring at half the length in the same 144-character run, moves the amber band from character 96 to character 48, and brings the pink sway forward to character 60.
02 RING_SPRING_STIFFNESS 220 → 110 With damping held at 14, lowering the stiffness from 220 to 110 makes the ring trail the target more slowly, and the damping ratio rises from 0.47 to 0.67 so the overshoot shrinks from about 19% to about 6%.
08 WARN_RATIO 0.8 → 0.9 On a 100-character limit, raising the ratio from 0.8 to 0.9 delays the amber shift and the close-to-limit hint from character 80 to character 90, so the comfort band grows by 10 characters and the caution band shrinks from 20 to 10.

All nine items have a prefers-reduced-motion: reduce branch read through matchMedia. In 01 through 08 that branch never starts the requestAnimationFrame loop, fills the demo text at once and draws the final state a single time, and later typing is drawn immediately without interpolation. Item 09 handles both the operating system setting and its own "Reduce motion" button (aria-pressed), and either one turns every interpolation into an immediate value. In 01 through 08 the only focusable control is the field, so Tab reaches it directly (09 adds its button), and since the number changes on every input, aria-live="polite" sits on one hidden status line instead of on the number and receives text only on rare moments such as entering a band, or every tenth character in item 01. The field is tied to its label and points at the count element through aria-describedby.

Why Does Counting with length Give the Wrong Number?

ta.value.length counts internal code units, not the characters a person sees, so an emoji or a combining mark makes it larger than the real text, which is why item 01 shows the two numbers side by side. For the parts around that calculation, Tag Input UI Patterns With CSS Chips covers chip input, CSS Input Design: 9 Form Widgets You Can Paste In covers the shape of the field itself, and 9 Copy Button CSS UI Patterns (Copy to Clipboard) covers feedback after a copy. How a typed value is shown is in Show Password Toggle Icon: 9 Reveal Patterns, and the search-box version of a character count is worth comparing in 9 CSS Input Focus Animations: One Line, Copy-Paste. The browser side of the count is documented in Intl.Segmenter and the field itself in textarea.

Frequently Asked Questions Before You Use a Character Counter

Should a textarea character limit counter block input once the limit is passed?

Most of these nine do not block, they signal: 02, 05 and 08 leave the extra characters in place and mark them with a ring, a highlight or a colour, and only 06 keeps the front of a paste and discards the rest. Whether to block or only show is the service's call, so it is easier to change later if the counter does the calculation and the signal and leaves the handling to the form.

Can I count with value.length alone?

For input that is only English letters and digits it is enough, but if an emoji or a combining mark can arrive, counting clusters with Intl.Segmenter as in 01 matches the number people see. Keep an Array.from fallback for environments without that object, and remember that in the fallback an emoji splits apart.

Do I need to show words and reading time as well?

If the goal is only a character limit, one of 02 or 08 is enough, and for a writing tool, putting four numbers on one line as in 07 reads naturally. Reading time is just the character count divided by READ_CHARS_PER_MIN, so one constant can be changed to fit the kind of text your service handles.

Enter the archive password

The password is inside this article. You will find it as you read.