Skip to content

CharacterCounter

MD · 1950B gzip budget

A character counter that counts graphemes, so one emoji costs one, not eleven.

Category
Forms & input
Budget
md, several states or a live subscription
External runtime
React / React DOM only
axe fixtureSSR rendersource scanbundle budget

Preview

Live fixture
Preview locale

7 of 20

Try the component here. Locale-aware formatting and direction follow the selected language; example content and labels are not automatically translated. Overlay demos use interactive launchers.

Why this exists

CharacterCounter

English

String#length counts code units. A user types one emoji and watches the counter jump by eleven, with no explanation.

Formatting shortcut

31 of 40

value.length

CharacterCounter

21 of 40

character-counter

Install

npx shadcn@latest add https://gear5-ui.vercel.app/r/character-counter.json

Copies the source into your project. Pulls in 4 shared registry items: cn, format, use-locale, locale.

New here? Set up Tailwind and import aliases first →

Source & props

Edit on GitHub ↗

Prop types and inline documentation are included below. This is repository source; the installer rewrites shared imports for your project.

"use client";

import { useMemo } from "react";
import { cn } from "../lib/cn";
import { numberFormat } from "../lib/format";
import { useLocale } from "../lib/use-locale";

export interface CharacterCounterProps {
  value: string;
  limit: number;
  /** `{count}` and `{limit}` are replaced with localised numbers. */
  template?: string;
  /** Announced when the limit is exceeded. `{over}` is the overage. */
  overTemplate?: string;
  className?: string;
}

/** Count user-perceived characters, not UTF-16 code units. */
function countGraphemes(text: string, locale: string): number {
  if (typeof Intl.Segmenter !== "function") return [...text].length;

  const segmenter = new Intl.Segmenter(locale, { granularity: "grapheme" });
  return [...segmenter.segment(text)].length;
}

/**
 * A character counter that counts the way the person typing counts.
 *
 * `value.length` counts UTF-16 code units, so a single emoji costs 2 and a
 * family emoji costs 11 — a user types one character and watches the counter
 * jump by eleven, with no explanation. Devanagari and Thai clusters have the
 * same problem.
 *
 * The count is a live region so it is available to screen readers, but polite
 * and only announced meaningfully near the limit; a region that speaks on
 * every keystroke makes a field unusable.
 */
export function CharacterCounter({
  value,
  limit,
  template = "{count} of {limit}",
  overTemplate = "{over} over the limit",
  className,
}: CharacterCounterProps) {
  const { locale, direction } = useLocale();

  const count = useMemo(() => countGraphemes(value, locale), [value, locale]);
  const over = count - limit;

  const format = (n: number) => numberFormat(locale).format(n);

  const text =
    over > 0
      ? overTemplate.replace("{over}", format(over))
      : template.replace("{count}", format(count)).replace("{limit}", format(limit));

  return (
    <p
      dir={direction}
      // Only assertive once the limit is actually breached: before that this
      // is reference information, not something worth interrupting typing for.
      role={over > 0 ? "alert" : "status"}
      aria-live={over > 0 ? "assertive" : "off"}
      className={cn(
        "text-start text-sm tabular-nums",
        over > 0 ? "text-red-700 dark:text-red-400" : "text-neutral-600 dark:text-neutral-400",
        className,
      )}
    >
      {text}
    </p>
  );
}

What CI checks

Quality contract
  • Bundled, minified and gzipped against its tier budget
  • Audited by axe in the state previewed above
  • Rendered through react-dom/server with no browser globals
  • Scanned for network calls, dangerous sinks, and unguarded animation