The "42 left" that sits under a field with a limit — a bio, a post box, a product description, an SMS body, a subject line — counting the characters a person can actually see rather than the code units the string happens to be made of, and showing the limit being crossed instead of quietly cutting the text off. Reach for it anywhere a length cap is real: a profile bio, headline or status; a comment, review, reply or chat composer; a post or tweet-style box; a product title and description; a support ticket, feedback or contact form; an SMS or push notification body; an email subject line; a meta description, OG description or alt text; a commit message or release note; a job posting, listing or classified; a survey free-text answer; and any textarea in front of a column that will reject what is too long. Common asks it answers: "character counter react", "textarea character count", "characters remaining react", "react character limit component", "maxlength counter react", "count remaining characters", "twitter style character counter", "shadcn character counter", "shadcn textarea character count", "string length wrong with emoji", "emoji counts as 2 characters javascript", "javascript count emoji as one character", "grapheme count javascript", "Intl.Segmenter count characters", "count unicode characters correctly", "utf8 byte length of a string", "varchar length frontend validation", "accessible character counter", "aria-live character count screen reader", "character count announced every keystroke", "文字数カウンター react", "絵文字 文字数 カウント". Official shadcn/ui has nothing for this: across its sixty-three components maxLength, charCount, Segmenter and codePoint are all zero hits, textarea is an eighteen-line bare element, and the two length matches inside field are errors.length in FieldError — so an agent asked for a counter writes value.length inline, and value.length is wrong for everyone whose text is not plain Latin. JavaScript counts UTF-16 code units, so one emoji is 2, a flag is 4, a thumbs-up with a skin tone is 4, and a family is 8. The writer is charged four characters for one glyph, and when they delete it the remaining count jumps back by four — which reads as a bug in the box, because it is one. Worse, the number on screen and the number the server enforces are then counted by different rules with nothing to warn you: Postgres varchar(n) and MySQL utf8mb4 count code points, a byte-bounded column counts bytes, and the field says "3 characters left" onto a save that comes back rejected. So the unit is a prop — grapheme by default, because that is the number a person would give you, with codePoint, utf16 and utf8 for the three things a back end usually means — and there is a crlfNewlines option for the mismatch nobody looks for, since a textarea reports every line break as \n while a submitted form normalises it to \r\n, leaving a ten-line post four characters longer on the wire than in the box. The second failure is the fix everyone reaches for first. Putting maxLength on the field looks like enforcement and behaves like a trapdoor: paste 400 characters into a 280 field and the browser keeps the first 280 and discards the rest with no event, no error and nothing on screen — the writer sees a full box, no complaint, and a sentence that ends mid-word, and what is missing is invisible precisely because it is missing. This component never sets it. The count goes negative and turns destructive, over is true, aria-invalid goes on the field, and refusing the save becomes one decision you make in the one place that already knows why. truncateToCount is exported for the times trimming really is the answer — a preview string, an OG description — and it walks grapheme clusters, so a UTF-16 or byte limit still lands on a boundary a person would recognise rather than leaving half a surrogate pair behind. Accessibility is the other half, and it is where hand-rolled counters do the most damage. The reflex is to wrap the number in aria-live, which turns every keystroke into an interruption; because a screen reader queues what it is told, the count ends up trailing several characters behind the typing while the letters themselves go unheard, and the field becomes unusable by the people the live region was added for. Here the counter is tied to the field with aria-describedby, read once on focus and silent after, and a separate polite region speaks only when the value crosses between comfortable, close to the limit and past it — three announcements in the life of a field, each of them news, each carrying the count at that moment. It stays quiet on mount too, so opening an existing bio that is already over does not talk at someone who has not typed anything yet. The visible number is aria-hidden so the description read on focus is "42 characters remaining" rather than a bare "42", the digits are tabular so the counter does not twitch sideways while you type, and every label is an overridable function so it translates. countChars, truncateToCount, charCountStatus, charCountMessage and the useCharCounter hook are all exported, so the same count that draws the counter can disable the submit button and back a zod refine instead of three places disagreeing. Within pulld it is the piece that goes under autosize-textarea, which is the field itself; it shares its Segmenter discipline with middle-truncate, which solves the same UTF-16 problem on the display side; and it follows the same describedby-plus-band-announcement pattern as password-strength, the other meter that lives under an input. One file, no dependencies at all — not even an icon — and every colour is a shadcn token, so it follows light and dark.
pnpm dlx shadcn@latest add "https://pulld.pages.dev/r/char-counter.json"