{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "char-counter",
  "title": "Character Counter",
  "description": "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.",
  "files": [
    {
      "path": "registry/ui/char-counter.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * How a limit is measured.\n *\n * The unit is the whole point of this component, because the number a person sees and the number\n * the server enforces are counted by different rules, and nothing warns you when they disagree.\n *\n *  - `\"grapheme\"` — user-perceived characters, the thing a person is actually counting. 👍🏽 is one,\n *    🇯🇵 is one, é written as e + combining acute is one. This is the default, and the only unit\n *    whose number matches what someone looking at the field would say if you asked them.\n *  - `\"codePoint\"` — Unicode scalars. What `[...value].length` gives, and what most databases mean\n *    by a character length: Postgres `char_length`/`varchar(n)`, MySQL `CHAR_LENGTH` and an\n *    `utf8mb4` `VARCHAR(n)`, Python's `len`, Go's rune count. 👍🏽 is 2 here (thumb + skin tone),\n *    🇯🇵 is 2 (two regional indicators).\n *  - `\"utf16\"` — UTF-16 code units. What `value.length`, the HTML `maxlength` attribute, Java and\n *    C# all count. Every non-BMP character — every emoji, every rarer CJK ideograph — is 2.\n *  - `\"utf8\"` — bytes. What a byte-bounded column or a payload cap enforces: Postgres\n *    `octet_length`, DynamoDB item sizes, most HTTP header and cookie limits. Latin text is 1 byte\n *    a character, most CJK is 3, most emoji is 4.\n *\n * Pick the one your server uses. A field that counts graphemes in front of a `varchar(280)` will\n * let someone past 280 with emoji in their text and then lose the save.\n */\nexport type CharCountUnit = \"grapheme\" | \"codePoint\" | \"utf16\" | \"utf8\"\n\n/** Where the current value sits relative to its limit. `\"near\"` drives the visual warning. */\nexport type CharCountStatus = \"ok\" | \"near\" | \"over\"\n\ntype GraphemeSegmenter = { segment(input: string): Iterable<{ segment: string }> }\ntype SegmenterConstructor = new (\n  locales: undefined,\n  options: { granularity: \"grapheme\" }\n) => GraphemeSegmenter\n\n// Built once and kept. Every function here runs on every keystroke, and constructing a Segmenter is\n// far more expensive than the segmentation itself. `undefined` means \"no locale preference\", which\n// is right: grapheme boundaries are defined by UAX #29 and are not locale-dependent the way word\n// and sentence boundaries are. `null` is the memo for \"this engine has no Segmenter\".\nlet cachedSegmenter: GraphemeSegmenter | null | undefined\nfunction graphemeSegmenter(): GraphemeSegmenter | null {\n  if (cachedSegmenter !== undefined) return cachedSegmenter\n  const Segmenter = (Intl as unknown as { Segmenter?: SegmenterConstructor }).Segmenter\n  cachedSegmenter =\n    typeof Segmenter === \"function\"\n      ? new Segmenter(undefined, { granularity: \"grapheme\" })\n      : null\n  return cachedSegmenter\n}\n\nlet cachedEncoder: TextEncoder | null | undefined\n\n/**\n * The newline problem, which is the same mismatch in a place nobody looks.\n *\n * A `<textarea>`'s `value` reports every line break as a single `\\n`, but the HTML spec has forms\n * normalise the value to CRLF on submit, and plenty of back ends store and count it that way. So a\n * 280-limit field holding ten lines is four short of what the counter claims under any unit but\n * graphemes — and it is short by more the longer the text gets, which is exactly when it matters.\n *\n * Turning this on counts a line break the way the wire does. Under `\"grapheme\"` it changes nothing,\n * because CRLF is a single grapheme cluster by definition — which is the correct answer there.\n */\nfunction toCrlf(text: string): string {\n  return text.replace(/\\r\\n|\\r|\\n/g, \"\\r\\n\")\n}\n\nfunction countCodePoints(text: string): number {\n  let n = 0\n  for (let i = 0; i < text.length; i++) {\n    const code = text.charCodeAt(i)\n    // A high surrogate followed by a low one is one code point written in two units. Checking both\n    // halves rather than just the first means an unpaired surrogate — which a truncated paste can\n    // leave behind — still counts as the one unit it is, instead of swallowing the character after it.\n    if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length) {\n      const next = text.charCodeAt(i + 1)\n      if (next >= 0xdc00 && next <= 0xdfff) i++\n    }\n    n++\n  }\n  return n\n}\n\nfunction countGraphemes(text: string): number {\n  const segmenter = graphemeSegmenter()\n  // Older engines: code points keep surrogate pairs whole, so an emoji counts 2 rather than 1\n  // instead of the 2-per-half a raw `.length` would report. It is the closest wrong answer available.\n  if (!segmenter) return countCodePoints(text)\n  let n = 0\n  for (const _segment of segmenter.segment(text)) n++\n  return n\n}\n\nfunction countUtf8Bytes(text: string): number {\n  if (cachedEncoder === undefined) {\n    cachedEncoder = typeof TextEncoder === \"function\" ? new TextEncoder() : null\n  }\n  if (cachedEncoder) return cachedEncoder.encode(text).length\n  let bytes = 0\n  for (const char of text) {\n    const code = char.codePointAt(0) as number\n    bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4\n  }\n  return bytes\n}\n\nexport interface CharCountOptions {\n  /** Count a line break as CRLF, the way a submitted form does. See {@link toCrlf}. */\n  crlfNewlines?: boolean\n}\n\n/**\n * How long `text` is, in the unit a server would agree with.\n *\n * This is the function the whole component exists to get right, and the reason it is exported on\n * its own: the same count has to drive the meter, the disabled state of the submit button, and the\n * `superRefine` on the schema, and those three disagreeing is the bug.\n */\nexport function countChars(\n  text: string,\n  unit: CharCountUnit = \"grapheme\",\n  options: CharCountOptions = {}\n): number {\n  const normalized = options.crlfNewlines ? toCrlf(text) : text\n  switch (unit) {\n    case \"utf16\":\n      return normalized.length\n    case \"codePoint\":\n      return countCodePoints(normalized)\n    case \"utf8\":\n      return countUtf8Bytes(normalized)\n    default:\n      return countGraphemes(normalized)\n  }\n}\n\n/** The user-perceived characters of `text`, longest boundary the engine can find. */\nfunction toAtoms(text: string): string[] {\n  const segmenter = graphemeSegmenter()\n  if (segmenter) return Array.from(segmenter.segment(text), (entry) => entry.segment)\n  return Array.from(text)\n}\n\n/**\n * Cut `text` down to `max` units, never through the middle of a character.\n *\n * Deliberately not what this component does to what you type — see {@link CharCounter} on why the\n * `maxlength` attribute is the wrong tool. It is here for the places where trimming really is the\n * answer and is being done on purpose: a preview string, an OG description, a value on its way into\n * a column that will reject it anyway.\n *\n * It walks grapheme clusters and weighs each one in the requested unit, so a UTF-16 or byte limit\n * still lands on a boundary a person would recognise. `\"👍🏽\".slice(0, 2)` is half a thumb.\n */\nexport function truncateToCount(\n  text: string,\n  max: number,\n  unit: CharCountUnit = \"grapheme\",\n  options: CharCountOptions = {}\n): string {\n  if (countChars(text, unit, options) <= max) return text\n  if (!(max > 0)) return \"\"\n  let used = 0\n  let out = \"\"\n  for (const atom of toAtoms(text)) {\n    const weight = unit === \"grapheme\" ? 1 : countChars(atom, unit, options)\n    if (used + weight > max) break\n    used += weight\n    out += atom\n  }\n  return out\n}\n\n/**\n * How close to the limit counts as close: a tenth of it, at least 1 and at most 20.\n *\n * Proportional because \"10 left\" is a different feeling in a 40-character title than in a 2,000\n * character description, and capped because on a very long limit a tenth is a warning that arrives\n * hundreds of characters early and stops meaning anything.\n */\nexport function defaultWarnAtRemaining(max: number): number {\n  return Math.min(20, Math.max(1, Math.ceil(max * 0.1)))\n}\n\nexport function charCountStatus(\n  count: number,\n  max: number | undefined,\n  warnAtRemaining?: number\n): CharCountStatus {\n  if (max === undefined || !Number.isFinite(max)) return \"ok\"\n  if (count > max) return \"over\"\n  const warnAt = warnAtRemaining ?? defaultWarnAtRemaining(max)\n  return max - count <= warnAt ? \"near\" : \"ok\"\n}\n\nexport interface CharCountState {\n  value: string\n  unit: CharCountUnit\n  /** Length of `value` in `unit`. */\n  count: number\n  max?: number\n  /** `max - count`, negative once over. `null` when there is no limit. */\n  remaining: number | null\n  status: CharCountStatus\n  /** `status === \"over\"`, kept as its own field because it is what a submit button is disabled on. */\n  over: boolean\n}\n\nexport interface CharCounterLabels {\n  /** Read out below the limit. Also used at exactly 0 remaining. */\n  remaining: (n: number) => string\n  /** Read out past the limit; `n` is how many units over. */\n  over: (n: number) => string\n  /** Read out when there is no limit at all. */\n  count: (n: number) => string\n}\n\nconst plural = (n: number, word: string) => `${n} ${word}${n === 1 ? \"\" : \"s\"}`\n\nexport const defaultCharCounterLabels: CharCounterLabels = {\n  remaining: (n) => `${plural(n, \"character\")} remaining`,\n  over: (n) => `${plural(n, \"character\")} over the limit`,\n  count: (n) => plural(n, \"character\"),\n}\n\n/** The sentence describing a count — the counter's accessible text, and what gets announced. */\nexport function charCountMessage(\n  count: number,\n  max: number | undefined,\n  labels: CharCounterLabels = defaultCharCounterLabels\n): string {\n  if (max === undefined || !Number.isFinite(max)) return labels.count(count)\n  return count > max ? labels.over(count - max) : labels.remaining(max - count)\n}\n\nexport interface UseCharCounterOptions extends CharCountOptions {\n  value: string\n  max?: number\n  /** Defaults to `\"grapheme\"`. Match it to your server. See {@link CharCountUnit}. */\n  unit?: CharCountUnit\n  /** Remaining count at which `status` becomes `\"near\"`. Defaults to {@link defaultWarnAtRemaining}. */\n  warnAtRemaining?: number\n  /** Id for the counter element. Generated when omitted. */\n  id?: string\n  /** Ids the field is already described by; merged ahead of the counter's own. */\n  describedBy?: string\n  labels?: Partial<CharCounterLabels>\n}\n\nexport interface UseCharCounterResult extends CharCountState {\n  counterId: string\n  labels: CharCounterLabels\n  /** Non-empty only in the moment a band boundary is crossed. Belongs in a polite live region. */\n  announcement: string\n  /** Spread onto the `<input>` or `<textarea>`. Deliberately carries no `maxLength`. */\n  fieldProps: {\n    \"aria-describedby\": string\n    \"aria-invalid\"?: true\n  }\n  /** Spread onto a {@link CharCounter} to render the state this hook already wired up. */\n  counterProps: CharCounterProps\n}\n\n/**\n * The counter without the markup: for a field that already has its own hint line, a layout the\n * component does not fit, or a submit button that needs to know.\n *\n * Announcements are the part worth reading. A counter cannot simply be a live region — an\n * `aria-live` on the number turns every keystroke into an interruption, and since a screen reader\n * queues what it is told, the reader ends up hearing the count trail several characters behind the\n * typing while the letters themselves go unheard. So the counter is wired to the field with\n * `aria-describedby`, which is read on focus and stays silent afterwards, and `announcement` fires\n * only when the value crosses between comfortable, close to the limit, and past it. Three\n * announcements in the life of a field, each of them news.\n */\nexport function useCharCounter({\n  value,\n  max,\n  unit = \"grapheme\",\n  crlfNewlines = false,\n  warnAtRemaining,\n  id,\n  describedBy,\n  labels: labelOverrides,\n}: UseCharCounterOptions): UseCharCounterResult {\n  const generatedId = React.useId()\n  const counterId = id ?? generatedId\n\n  const labels = React.useMemo(\n    () => ({ ...defaultCharCounterLabels, ...labelOverrides }),\n    [labelOverrides]\n  )\n\n  const count = React.useMemo(\n    () => countChars(value, unit, { crlfNewlines }),\n    [value, unit, crlfNewlines]\n  )\n  const status = charCountStatus(count, max, warnAtRemaining)\n  const over = status === \"over\"\n\n  const [announcement, setAnnouncement] = React.useState(\"\")\n  // Seeded with the status at mount, so a value that arrives already over the limit — an existing\n  // bio being edited — is described rather than announced at somebody who has not typed anything yet.\n  const lastStatus = React.useRef(status)\n  React.useEffect(() => {\n    if (lastStatus.current === status) return\n    lastStatus.current = status\n    setAnnouncement(charCountMessage(count, max, labels))\n  }, [status, count, max, labels])\n\n  const fieldProps = React.useMemo(\n    () => ({\n      \"aria-describedby\": [describedBy, counterId].filter(Boolean).join(\" \"),\n      // The value will be rejected, so say so where a screen reader will hear it on the field\n      // itself. Drop it if you are already managing validity from your form library.\n      ...(over ? { \"aria-invalid\": true as const } : {}),\n    }),\n    [describedBy, counterId, over]\n  )\n\n  const counterProps: CharCounterProps = {\n    id: counterId,\n    value,\n    max,\n    unit,\n    crlfNewlines,\n    warnAtRemaining,\n    labels: labelOverrides,\n  }\n\n  return {\n    value,\n    unit,\n    count,\n    max,\n    remaining: max === undefined ? null : max - count,\n    status,\n    over,\n    counterId,\n    labels,\n    announcement,\n    fieldProps,\n    counterProps,\n  }\n}\n\nexport interface CharCounterProps\n  extends Omit<React.ComponentPropsWithoutRef<\"span\">, \"children\">,\n    CharCountOptions {\n  /** The field's current value. Controlled input only — there is nothing to count otherwise. */\n  value: string\n  max?: number\n  unit?: CharCountUnit\n  warnAtRemaining?: number\n  /** Replaces the visible number. The accessible text is unaffected. */\n  format?: (state: CharCountState) => React.ReactNode\n  labels?: Partial<CharCounterLabels>\n}\n\nconst STATUS_CLASS: Record<CharCountStatus, string> = {\n  ok: \"text-muted-foreground\",\n  near: \"text-foreground\",\n  over: \"text-destructive font-medium\",\n}\n\n/**\n * The \"42 left\" under a bio, a post box, a product description, an SMS body or a subject line.\n *\n * Point the field at it and the field describes itself:\n *\n * ```tsx\n * <textarea id=\"bio\" value={bio} onChange={(e) => setBio(e.target.value)} aria-describedby=\"bio-count\" />\n * <CharCounter id=\"bio-count\" value={bio} max={280} />\n * ```\n *\n * or let {@link useCharCounter} do the wiring, and use what it knows:\n *\n * ```tsx\n * const counter = useCharCounter({ value: bio, max: 280, unit: \"codePoint\" })\n * <textarea value={bio} onChange={(e) => setBio(e.target.value)} {...counter.fieldProps} />\n * <CharCounter {...counter.counterProps} />\n * <button disabled={counter.over}>Save</button>\n * ```\n *\n * **It counts, and it does not stop you.** The reflex is to put `maxLength` on the field and be\n * done, and that attribute is a trapdoor: paste 400 characters into a 280 field and the browser\n * keeps the first 280 and discards the rest with no event, no error and nothing on screen. The\n * writer sees a full box and no complaint; the sentence they were pasting ends mid-word. What is\n * missing is invisible precisely because it is missing. So the limit is shown and crossed — the\n * count goes negative and red, `over` is true, and refusing the save is a decision you make once,\n * in the one place that already knows why.\n *\n * The number is a real count of what the person can see, not `value.length`. Those differ wherever\n * text is not plain Latin: an emoji is 2 UTF-16 units, a flag is 4, a thumbs-up with a skin tone is\n * 4, so the naive counter charges someone four characters for one glyph — and deleting it makes the\n * remaining count jump by four, which reads as a bug in the box.\n */\nexport function CharCounter({\n  value,\n  max,\n  unit = \"grapheme\",\n  crlfNewlines = false,\n  warnAtRemaining,\n  format,\n  labels: labelOverrides,\n  className,\n  id,\n  ...props\n}: CharCounterProps) {\n  const counter = useCharCounter({\n    value,\n    max,\n    unit,\n    crlfNewlines,\n    warnAtRemaining,\n    id,\n    labels: labelOverrides,\n  })\n  const { count, remaining, status, counterId, labels, announcement } = counter\n  const spoken = charCountMessage(count, max, labels)\n\n  return (\n    <>\n      <span\n        id={counterId}\n        className={cn(\n          // Tabular figures because the number changes on every keystroke, and proportional digits\n          // make the counter twitch sideways in the corner of the writer's eye while they type.\n          \"text-xs tabular-nums\",\n          STATUS_CLASS[status],\n          className\n        )}\n        {...props}\n      >\n        {/* Hidden from assistive tech so the description read on focus is the sentence below rather\n            than a bare \"42\", which on its own says nothing about what it counts or which way it runs. */}\n        <span aria-hidden=\"true\">\n          {format ? format(counter) : remaining === null ? count : remaining}\n        </span>\n        <span className=\"sr-only\">{spoken}</span>\n      </span>\n      {/* Kept out of the described element: anything in here would be read on every focus, and the\n          description would then be the last band change rather than the current count. */}\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\">\n        {announcement}\n      </span>\n    </>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 103 components: https://pulld.pages.dev/?utm_source=cli"
}
