Char Counter
A character budget for any input or textarea — grapheme-accurate counting, a warning band, an explicit over-limit state, and a hard cap that trims the paste instead of swallowing it.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/char-counter.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "CharCounter" component (no external deps).
Contract
- forwardRef<HTMLDivElement>, spreads the rest of its div props; className merged with cn().
- Controlled: value: string, onValueChange?: (next: string) => void. It never
stores the text itself.
- limit: number — the ceiling, expressed in `granularity` units.
- enforce?: "soft" | "hard" (default "soft").
- granularity?: "grapheme" | "codepoint" | "utf16" (default "grapheme").
- warnAt?: number (default 0.9) — fraction of limit where the warning band starts.
- children: (field) => ReactNode — a render prop. `field` carries value, onChange,
aria-describedby (pointing at the readout) and aria-invalid, so the same
component works on an <input>, a <textarea> or any styled wrapper around one.
- Also export countCharacters(value, granularity) so the form's validator, the
zod schema and the server can share one caliber instead of inventing three.
Behavior
- Counting is grapheme-cluster based by default, via one module-level
Intl.Segmenter("en", { granularity: "grapheme" }) constructed lazily and
reused (constructing it per keystroke is the expensive part), falling back to
Array.from (code points) on engines without Segmenter. This matters because
"👍".length is 2, "🇨🇳".length is 4 and "👨👩👧👦".length is 11, while all
three are one thing a person can delete with one Backspace.
- granularity is a prop, not a constant, because the *server* may count
differently. A field that says "3 characters left" while the API rejects the
payload is unexplainable to the user; the fix is to set the caliber the API
uses, not to argue about Unicode. Same reason countCharacters is exported —
the validator must not re-implement the count.
- Newlines: "\n" is 1 under every caliber, but "\r\n" is 1 grapheme and 2 UTF-16
units (Unicode never breaks CR from LF), and HTML form submission normalises
textarea line breaks to CRLF — a 10-line note reaches the server 9 units
longer than str.length on the live DOM value says. Document it; normalise
before counting if the server counts the submitted payload.
- CJK: a common ideograph is 1 under all three calibers (it is inside the BMP).
The component counts characters, never words — a word count for Chinese needs
word-granularity segmentation and a mixed-script policy, which is a different
component. Platforms that *weight* CJK at 2 are expressing a business rule:
put it in `limit`, not in the caliber.
- Zones: over (used > limit) / full (used === limit) / warn (used >= ceil(limit *
warnAt)) / ok. aria-invalid is set only in `over`, and only ever as true or
absent, so the consumer's own error state is never overwritten with "valid".
- enforce="hard" does NOT use the native maxLength attribute. Two reasons, both
disqualifying: maxLength counts UTF-16 units only, so it cannot express a
grapheme or code-point limit at all; and it truncates a paste silently — the
head of the text appears, the tail is gone, and nothing tells the user. Instead
intercept in onChange: diff previous vs incoming on cluster boundaries (common
prefix + common suffix), trim only the *inserted* run to whatever budget is
left, and render how many clusters were refused. Deletions always pass through
untouched. Cutting is always on grapheme boundaries even under a utf16 caliber
— the caliber decides what a cluster costs, never where it is legal to cut, so
a trim can never produce a lone surrogate or an orphaned ZWJ.
- After a trim, write the trimmed string back onto the DOM node and restore the
caret synchronously inside the handler (min(node.selectionStart, end of the
accepted insertion), guarded in try/catch because email/number inputs throw on
setSelectionRange). Otherwise React's controlled-input restore rewrites the
node and flings the caret to the end of the text on every mid-text edit.
- A value that arrives already over a hard limit (server data, or a limit that
was lowered later) is never retroactively truncated: it renders over-limit and
stays editable downwards. Silently deleting text nobody typed this session is
worse than an honest error.
- limit of 0, negative, NaN or Infinity all mean "no usable ceiling": fall back
to a plain count with no warning band, no over state and no blocking. warnAt
is clamped to 0–1.
Rendering & styling
- Readout "<used> / <limit>", right-aligned, tabular-nums, with a fixed
reservation: an invisible aria-hidden twin of the widest reading this field can
show (one digit past the limit's own width) sits in the same CSS grid cell, so
the box never resizes when 9 becomes 10 or 99 becomes 100. tabular-nums alone
only equalises digit *widths*; it does not stop the digit *count* from moving
everything to its left. Pin the twin to the heaviest weight the readout can
reach — measured in Edge, a medium "100 / 100" is ~1px wider than a regular
one even in tabular figures, so a twin that inherited the zone's weight would
still let the box breathe on every crossing.
- Escalation ladder is text-muted-foreground -> text-foreground -> font-medium ->
text-destructive, never a raw hex/oklch. Colour is not the only cue: the number
visibly passes the limit, the weight changes, the sr-only text says how far
over, and the field goes aria-invalid.
- Accessibility: the readout carries an id and an sr-only sentence ("12 of 160
characters used, 4 characters over the limit") and is referenced by the field's
aria-describedby, so it is read on focus. A *separate* aria-live="polite"
sr-only region holds a sentence derived only from the zone — so it changes when
the user crosses a threshold and at no other time. Never put the running count
in the live region: a screen reader would read a number out loud on every
keystroke, which is how an "accessible" counter becomes unusable. The live
region stays empty until the user has actually edited the field, so mounting a
card that is already over the limit does not announce itself.
- Only decoration is transition-colors with motion-reduce:transition-none; every
state is legible with animation off.
Customization levers
- granularity + countCharacters: switch the caliber to whatever the backend
counts, and reuse the same function in the zod schema so the two can never
drift apart.
- warnAt: how early the warning band opens (0.8 for long-form, 1 to disable it
and go straight from ok to full).
- enforce: "soft" for prose where going over is a review problem, "hard" for
fields with a protocol-level ceiling (SMS segments, a database column).
- Readout copy/layout: swap "<used> / <limit>" for "N left" or move the readout
into the corner of the field — keep the invisible reservation twin in sync
with whatever string you render, that is what holds the width still.
- Ladder tokens: the ok/warn/full/over classes are three token swaps if your
design system has a dedicated warning colour.Concepts
- Grapheme-cluster counting — the default caliber counts what a person would call a character: one flag, one family emoji, one accented letter, each deleted by one Backspace.
Intl.Segmenteratgraphemegranularity does the segmentation; engines without it fall back to code points, which at least keeps surrogate pairs whole. - Caliber as a contract —
grapheme/codepoint/utf16is a prop because the ceiling is usually enforced twice, once in the field and once on the server. When the two disagree the user sees "3 left" and gets rejected anyway, with nothing on screen to explain it; exportingcountCharacterslets the validator share the exact function instead of approximating it. - Soft versus hard limit — a soft limit lets the text through and flags it (
aria-invalid, destructive readout), leaving submission to the form; a hard limit refuses the part of the edit that does not fit. They are different products: soft is editorial guidance, hard is a protocol ceiling. - Edit-diff trimming — a hard cap trims the insertion, recovered by a common-prefix/common-suffix diff, not the tail of the string.
next.slice(0, limit)looks identical while typing at the end and quietly deletes pre-existing text the moment someone pastes into the middle of a full field. - Threshold-only announcement — the polite live region holds a sentence derived from the zone alone, so it changes on a crossing and stays byte-identical while the count runs. The live count lives in
aria-describedbyinstead, read on focus rather than shouted per keystroke. - Reserved-width readout — an invisible twin of the widest reading shares a grid cell with the live one, so a
limit={100}field is already as wide as9999 / 100before the first keystroke, and the twin is pinned to the heaviest weight the readout can reach.tabular-numsequalises digit widths but does nothing about a digit being added, and a medium100 / 100is measurably wider than a regular one even in tabular figures.
Meeting Planner
Find a time across time zones — participant rows over one shared 24-hour track, a draggable column that reads the same instant in every zone, hour snapping with Shift for half hours, and working hours derived from Intl.
Number Ticker
A count-up number that eases from 0 to its target the first time it scrolls into view — Intl-formatted, prefix/suffix aware.