CJK Word Count Textarea
A React textarea with CJK-aware counting: switch live between word, character and byte modes, a limit progress ring, near-limit and over-limit warnings, frozen counts while an IME is composing, and an optional hard limit.
A Chinese "word count" is not the same thing as an English one: every Han character counts as one, while an English run of letters counts as one word as a whole — and databases or legacy systems often count bytes instead. This textarea puts all three counting modes into a segmented switch at the bottom right and shows "current / limit" live next to an SVG progress ring that turns amber past the warnAt ratio and red, with a quick shake, once the limit is exceeded. While an IME (Zhuyin, kana and so on) is composing, the count is frozen so composition letters are never counted; with hardLimit on, input past the limit is blocked, but trimming waits until composition ends so a character being composed is never cut in half. Both the text value and the counting mode support controlled and uncontrolled modes.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add cjk-word-count-textarea.enPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<CjkWordCountTextarea/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
Installation
npx webberui@latest add https://webberui.com/r/cjk-word-count-textarea.en.jsonOr, once registries are configured in components.json, install it as @webberui/cjk-word-count-textarea.
Usage
import { CjkWordCountTextarea } from "@/components/ui/cjk-word-count-textarea";
<CjkWordCountTextarea
label="Post content"
maxCount={140}
defaultMode="words"
autoResize
onChange={(text, counts) => {
// counts carries the numbers for all three modes
console.log(counts.words, counts.chars, counts.bytes);
}}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Controlled text value; when omitted the component is uncontrolled |
defaultValue | string | "" | Initial text in uncontrolled mode |
onChange | (text: string, counts: CjkCounts) => void | — | Callback when the text changes; the second argument holds the live counts for all three modes |
maxCount | number | — | Count limit (in the current mode's units); when omitted only the count is shown and no ring is drawn |
mode | "words" | "chars" | "bytes" | — | Controlled counting mode, used together with onModeChange |
defaultMode | "words" | "chars" | "bytes" | "words" | Initial counting mode in uncontrolled mode |
onModeChange | (mode: CjkCountMode) => void | — | Callback when the counting mode changes |
warnAt | number | 0.8 | Ratio (0–1) at which the amber warning state starts |
rows | number | 4 | Number of visible textarea rows |
autoResize | boolean | false | Whether the height grows with the content (based on scrollHeight) |
label | string | — | Field label; when omitted the placeholder is used as the accessible name |
placeholder | string | "輸入內容⋯" | Placeholder text (default reads "type something…") |
hardLimit | boolean | false | Block input past the limit; nothing is trimmed while an IME is composing, only once composition ends |
countOptions | CountCjkOptions | — | Counting options passed to countCjk (full-width punctuation, Big5 byte weighting) |
labels | CjkWordCountLabels | — | UI text overrides (mode labels and warning announcements) |
className | string | — | Forwarded to the outermost container and merged with cn() |
CjkCounts
| Field | Type | Default | Description |
|---|---|---|---|
words | number | — | CJK-style word count: each Han character counts as one, Latin runs are split on whitespace |
chars | number | — | Character count, by Unicode code point |
bytes | number | — | Byte count: UTF-8 length, or weighted by bytesPerCjk |
CountCjkOptions
| Field | Type | Default | Description |
|---|---|---|---|
countPunctuation | boolean | false | In words mode, also count each full-width punctuation mark (,。!?…) as one |
bytesPerCjk | number | — | In bytes mode, a fixed byte weight for every CJK character (including full-width punctuation); when omitted the real UTF-8 length is used |
CjkWordCountLabels
| Field | Type | Default | Description |
|---|---|---|---|
words | string | "字數" | Words mode label (default reads "words") |
chars | string | "字元" | Chars mode label (default reads "characters") |
bytes | string | "位元組" | Bytes mode label (default reads "bytes") |
modeGroup | string | "計數模式" | Accessible name of the mode switch group (default reads "counting mode") |
nearLimit | string | "接近上限" | Screen reader announcement prefix when crossing warnAt (default reads "near the limit") |
overLimit | string | "已超過上限" | Screen reader announcement prefix when over the limit (default reads "over the limit") |
countCjk(text, mode, opts) and clampCjk(text, max, mode, opts) are also named exports, so form validation or server code can count and trim with exactly the same rules; both are pure functions with no browser API dependency.
How it works
- Words mode: each Han character (plus Japanese kana and Korean Hangul) counts as one; runs of Latin letters, digits and the like are split on whitespace or CJK characters and count as one word each; full-width punctuation is not counted by default, or counted one by one with
countPunctuation: true. For example, "我買了 3 本 React 書" counts as 7. - Chars mode counts Unicode code points (
Array.from), so surrogate pairs such as emoji are never split in two. - Bytes mode defaults to UTF-8 length (most Han characters are 3 bytes); many legacy systems in Taiwan count in Big5 (2 bytes per Han character), which you can match with
countOptions={{ bytesPerCjk: 2 }}. - The hard limit uses binary search to find the longest valid prefix: the count only grows as the prefix grows, so there is no need to recount character by character.
autoResizeresets the height before readingscrollHeight, so the field shrinks back when content is removed.
Accessibility
- The
labelis associated with the textarea viahtmlFor; without alabel, theplaceholderbecomes thearia-label - The count text is linked to the textarea via
aria-describedby;aria-invalidis set when over the limit - The mode switch is a
role="radiogroup"whose tabs arerole="radio"witharia-checked, using a roving tabindex; arrow keys cycle through modes, with a focus-visible ring - Warnings live in a
role="status",aria-live="polite"region and are announced only once, at the momentwarnAtor the limit is crossed, rather than on every keystroke; the progress ring and warning icon are hidden from assistive technology - When the user has reduce motion enabled at the system level, the over-limit shake and the sliding mode pill animation are disabled and the state switches directly
Taiwan Stock Quote Card
A Taiwan-convention stock quote card for React: red for up and green for down, limit-up/down locked styling, rolling flashing price digits, a change badge, open/high/low/previous-close cells, a volume bar and a compact 5-level order book — all data supplied via props.
Language Flip Text
Flips one sentence between languages with a split-flap or vertical roll, and can settle on a chosen locale — demoed in Traditional Chinese, English and Japanese.