Inputs

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/**
 * Which unit the limit is expressed in. This is the single most important prop:
 * the same string has three different lengths.
 *
 *   "👍"        grapheme 1 · codepoint 1 · utf16 2
 *   "🇨🇳"        grapheme 1 · codepoint 2 · utf16 4
 *   "👨‍👩‍👧‍👦"  grapheme 1 · codepoint 7 · utf16 11
 *   "é" (U+00E9) grapheme 1 · codepoint 1 · utf16 1
 *   "é" (e + U+0301) grapheme 1 · codepoint 2 · utf16 2

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/char-counter.json

Prompt

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.Segmenter at grapheme granularity does the segmentation; engines without it fall back to code points, which at least keeps surrogate pairs whole.
  • Caliber as a contractgrapheme / codepoint / utf16 is 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; exporting countCharacters lets 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-describedby instead, 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 as 9999 / 100 before the first keystroke, and the twin is pinned to the heaviest weight the readout can reach. tabular-nums equalises digit widths but does nothing about a digit being added, and a medium 100 / 100 is measurably wider than a regular one even in tabular figures.

On This Page