form.character-count
The GOV.UK character count message for a text box: characters or words left or over a limit, with a threshold.
1.0.1 · published 2026-10-03 by charlie · Anterra
Pinned by 32 tests, run in TypeScript, Python and Rust.
What it does
The message under a text box with a limit, worded exactly as the GOV.UK Design System's character count: "You have 12 characters remaining", "You have 1 character remaining", "You have 0 characters remaining", "You have 3 characters too many", "You have 1 word too many". It returns the numbers as well, so a form can mark the box in error when `over`, and the server can refuse the same text with the same count:
characterCount("Hello", 10, "characters", null)
// { count: 5, remaining: 5, over: false, visible: true,
// message: "You have 5 characters remaining",
// description: "You can enter up to 10 characters" }
For example
character_count(Hello, 10, characters, —)→ count 5, remaining 5, over false, visible true, message You have 5 characters remaining, description You can enter up to 10 characters under the limit: characters left, pluralcharacter_count(, 200, characters, —)→ count 0, remaining 200, over false, visible true, message You have 200 characters remaining, description You can enter up to 200 characters nothing typed yet: the whole limit is leftcharacter_count(abcd, 5, characters, —)→ count 4, remaining 1, over false, visible true, message You have 1 character remaining, description You can enter up to 5 characters one left is singular
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
def character_count(text: str, limit: int, unit: CountUnit, threshold: Optional[int]) -> CharacterCount
| text | string | what has been typed so far, exactly as it is |
| limit | int | the most characters or words allowed, 1 or more |
| unit | CountUnit | count characters (Unicode code points) or words (runs of non-whitespace) |
| threshold | int? | percentage of the limit below which the message is not shown, 0 to 100; null shows it always |
| returns | CharacterCount | the count, what is left, and GOV.UK's message |
The types it declares, generated into your project
CountUnit = Literal["characters", "words"]
@dataclass(frozen=True)
class CharacterCount:
"""How much has been typed against a limit, and what to tell the person."""
#: characters or words typed
count: int
#: limit less count; negative when over the limit
remaining: int
#: more than the limit has been typed
over: bool
#: the message should be shown: always when over, else when count reaches the threshold
visible: bool
#: "You have 12 characters remaining", "You have 1 word too many"
message: str
#: the limit said up front, "You can enter up to 200 characters", for a page without JavaScript
description: str
Your code names it in one line, in the file that uses it
from fune.form.character_count import character_count # form.character-count@^1
from typing import Any, Optional
from .form_character_count_types import CharacterCount, CountUnit
def _whole(value: Any) -> bool:
# bool is an int in Python; True is not a limit of one here.
return isinstance(value, int) and not isinstance(value, bool)
def _is_whitespace(ch: str) -> bool:
"""The Unicode White_Space property, spelled out so all three languages
agree: str.isspace also accepts U+001C-U+001F, which JavaScript and Rust
do not.
"""
cp = ord(ch)
return (
0x09 <= cp <= 0x0D
or cp == 0x20
or cp == 0x85
or cp == 0xA0
or cp == 0x1680
or 0x2000 <= cp <= 0x200A
or cp == 0x2028
or cp == 0x2029
or cp == 0x202F
or cp == 0x205F
or cp == 0x3000
)
def _count_words(text: str) -> int:
words = 0
in_word = False
for ch in text:
if _is_whitespace(ch):
in_word = False
elif not in_word:
in_word = True
words += 1
return words
def character_count(text: str, limit: int, unit: CountUnit, threshold: Optional[int]) -> CharacterCount:
"""GOV.UK's character count: how many characters (code points) or words
have been typed against a limit, and the sentence to show under the box.
"""
if not isinstance(text, str):
raise TypeError("text must be a string")
if not _whole(limit) or limit < 1:
raise ValueError("limit must be a whole number of at least 1, received %r" % (limit,))
if unit not in ("characters", "words"):
raise ValueError("unit must be characters or words, received %r" % (unit,))
if threshold is not None and (not _whole(threshold) or threshold < 0 or threshold > 100):
raise ValueError("threshold must be a whole number from 0 to 100, received %r" % (threshold,))
# A Python str is indexed by code point already.
count = _count_words(text) if unit == "words" else len(text)
remaining = limit - count
over = remaining < 0
# count >= limit * threshold / 100, kept in whole numbers so 50% of 3 is 1.5, not 1.
visible = over or threshold is None or count * 100 >= limit * threshold
one = "word" if unit == "words" else "character"
def noun(n: int) -> str:
return one if n == 1 else unit
if over:
message = "You have %d %s too many" % (-remaining, noun(-remaining))
else:
message = "You have %d %s remaining" % (remaining, noun(remaining))
return CharacterCount(
count=count,
remaining=remaining,
over=over,
visible=visible,
message=message,
description="You can enter up to %d %s" % (limit, noun(limit)),
)Install
fune build
With that line in your source, in a Python project (language python in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add form.character-count
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./form.character-count-1.0.1-python.fune, or fetch it from a terminal with fune pull form.character-count@1.0.1:python.
The whole function, every language, is one file too: form.character-count-1.0.1.fune, 25,373 bytes, sha256 09734b67f0d2f813a074ca016c5da79fa9a3dd8e621c10f984d1144a87919df2. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
# fune: before form.character-count
after — your function gets the result and the arguments, and returns the final result.
# fune: after form.character-count
replace — it requires no other capability, so there is no dependency to replace.
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show form.character-count --steps.
# fune: step form.character-count after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| under the limit: characters left, plural | Hello, 10, characters, — | → | count 5, remaining 5, over false, visible true, message You have 5 characters remaining, description You can enter up to 10 characters |
| nothing typed yet: the whole limit is left | , 200, characters, — | → | count 0, remaining 200, over false, visible true, message You have 200 characters remaining, description You can enter up to 200 characters |
| one left is singular | abcd, 5, characters, — | → | count 4, remaining 1, over false, visible true, message You have 1 character remaining, description You can enter up to 5 characters |
| exactly at the limit: 0 characters remaining, not over | abcde, 5, characters, — | → | count 5, remaining 0, over false, visible true, message You have 0 characters remaining, description You can enter up to 5 characters |
| one over is singular, and remaining goes negative | abcdef, 5, characters, — | → | count 6, remaining -1, over true, visible true, message You have 1 character too many, description You can enter up to 5 characters |
| three over | abcdefgh, 5, characters, — | → | count 8, remaining -3, over true, visible true, message You have 3 characters too many, description You can enter up to 5 characters |
| an emoji is one character, not two UTF-16 units | 😀😀😀, 3, characters, — | → | count 3, remaining 0, over false, visible true, message You have 0 characters remaining, description You can enter up to 3 characters |
| a decomposed accent (e + combining acute) is two code points | Café, 5, characters, — | → | count 5, remaining 0, over false, visible true, message You have 0 characters remaining, description You can enter up to 5 characters |
| spaces and line breaks are characters too | a b c, 10, characters, — | → | count 5, remaining 5, over false, visible true, message You have 5 characters remaining, description You can enter up to 10 characters |
| a limit of 1 is said in the singular | , 1, characters, — | → | count 0, remaining 1, over false, visible true, message You have 1 character remaining, description You can enter up to 1 character |
Show the other 22 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| words: runs of non-whitespace | The quick brown fox, 10, words, — | → | count 4, remaining 6, over false, visible true, message You have 6 words remaining, description You can enter up to 10 words |
| words: leading, trailing and repeated whitespace, tabs and line breaks do not make words | one two three , 3, words, — | → | count 3, remaining 0, over false, visible true, message You have 0 words remaining, description You can enter up to 3 words |
| one word left is singular | one two, 3, words, — | → | count 2, remaining 1, over false, visible true, message You have 1 word remaining, description You can enter up to 3 words |
| one word too many | a b c d, 3, words, — | → | count 4, remaining -1, over true, visible true, message You have 1 word too many, description You can enter up to 3 words |
| three words too many | a b c d e f, 3, words, — | → | count 6, remaining -3, over true, visible true, message You have 3 words too many, description You can enter up to 3 words |
| only whitespace is no words | , 5, words, — | → | count 0, remaining 5, over false, visible true, message You have 5 words remaining, description You can enter up to 5 words |
| a no-break space separates words; punctuation does not | one two,three, 5, words, — | → | count 2, remaining 3, over false, visible true, message You have 3 words remaining, description You can enter up to 5 words |
| below the threshold the message is not shown, but still worked out | abcdefghij, 20, characters, 75 | → | count 10, remaining 10, over false, visible false, message You have 10 characters remaining, description You can enter up to 20 characters |
| exactly at the threshold it is shown | abcdefghijklmno, 20, characters, 75 | → | count 15, remaining 5, over false, visible true, message You have 5 characters remaining, description You can enter up to 20 characters |
| 50% of 3 is 1.5: one character is below it (whole-number division would say 1 and show it) | a, 3, characters, 50 | → | count 1, remaining 2, over false, visible false, message You have 2 characters remaining, description You can enter up to 3 characters |
| 50% of 3 is 1.5: two characters reach it | ab, 3, characters, 50 | → | count 2, remaining 1, over false, visible true, message You have 1 character remaining, description You can enter up to 3 characters |
| a threshold of 0 shows the message with nothing typed | , 10, characters, 0 | → | count 0, remaining 10, over false, visible true, message You have 10 characters remaining, description You can enter up to 10 characters |
| a threshold of 100 hides it until the limit | abcdefghi, 10, characters, 100 | → | count 9, remaining 1, over false, visible false, message You have 1 character remaining, description You can enter up to 10 characters |
| over the limit is always shown, whatever the threshold | abcdefghijk, 10, characters, 100 | → | count 11, remaining -1, over true, visible true, message You have 1 character too many, description You can enter up to 10 characters |
| the threshold applies to words when counting words | a b, 10, words, 50 | → | count 2, remaining 8, over false, visible false, message You have 8 words remaining, description You can enter up to 10 words |
| a limit of 0 is refused | abc, 0, characters, — | → | error: limit must be a whole number of at least 1 |
| a negative limit is refused | abc, -5, words, — | → | error: limit must be a whole number of at least 1 |
| a fractional limit is refused | abc, 2.5, characters, — | → | error: limit must be a whole number of at least 1 |
| a threshold over 100 is refused | abc, 10, characters, 101 | → | error: threshold must be a whole number from 0 to 100 |
| a negative threshold is refused | abc, 10, characters, -1 | → | error: threshold must be a whole number from 0 to 100 |
| a fractional threshold is refused | abc, 10, characters, 50.5 | → | error: threshold must be a whole number from 0 to 100 |
| an unknown unit is refused | abc, 10, letters, — | → | error: unit must be characters or words |
More from the author
`react.form.textarea` shows it live as the person types. It is written in all three languages because the server has to enforce the same limit: a count the browser and the API disagree on is a form that says "0 remaining" and then rejects the post.
## Decisions
- **Characters are Unicode code points.** An emoji is 1, not the 2 UTF-16 units JavaScript's `length` (and GOV.UK's own script) counts, so all three languages agree. A decomposed accent (`e` plus a combining acute) is 2. Text is counted as given: a browser posts a line break in a textarea as `\r\n`, which is 2 characters, so normalise line breaks before counting on the server if the limit must match the browser's. - **Words are runs of anything but whitespace**, like GOV.UK's `/\S+/g`, with whitespace spelled out as the Unicode White_Space set (tab to carriage return, space, U+0085, no-break space, U+1680, U+2000-U+200A, the line and paragraph separators, U+202F, U+205F, U+3000) so every language splits alike. Punctuation does not split: `two,three` is one word. - **The threshold** is a percentage of the limit below which the message is not shown (`visible` false), as GOV.UK's `threshold` option. The comparison is `count * 100 >= limit * threshold` in whole numbers, so 50% of 3 is 1.5 and one character stays below it. `null` shows the message always; so does being over the limit, whatever the threshold. The message is worked out even when not visible. - **remaining** goes negative when over; `message` says the positive number "too many". - **description** is GOV.UK's `textareaDescriptionText`, "You can enter up to 200 characters" (singular for a limit of 1), for a page before its script runs.
Errors, identical in every language: `limit must be a whole number of at least 1`, `threshold must be a whole number from 0 to 100`, `unit must be characters or words`.
Sources: GOV.UK Design System, "Character count" https://design-system.service.gov.uk/components/character-count/; the wording and the threshold rule from govuk-frontend's `character-count.mjs` (`charactersUnderLimit`, `charactersAtLimit`, `charactersOverLimit`, `wordsUnderLimit`, `wordsAtLimit`, `wordsOverLimit`, `isOverThreshold`) https://github.com/alphagov/govuk-frontend/blob/main/packages/govuk-frontend/src/govuk/components/character-count/character-count.mjs.
## Notices
Portions derived from GOV.UK Frontend (https://github.com/alphagov/govuk-frontend), Copyright (c) 2017 Crown Copyright (Government Digital Service), under the MIT License; the full notice is in NOTICE.
1.0.1 adds its attribution notices (NOTICE). The code and the tests are unchanged.
Files
| Path | Bytes |
|---|---|
| NOTICE | 1,218 |
| README.md | 3,314 |
| impl/python.py | 2,699 |
| impl/rust.rs | 3,479 |
| impl/typescript.ts | 2,482 |
| vectors.json | 7,736 |