Character counter
A soft-limit counter for a field's footer: a ring that fills toward the limit beside '92 characters remaining', a warning near it and an error mark past it. Counts graphemes or words and never truncates.
cmp_character_counter_01 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_character_counter_01 version 1.0.0 with variant "blue", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Blue accent
- Version
- 1.0.0
- Digest
Full digest
sha256-4b81a165a26952ee6c99daa9ef692c4fc0d0a2961d7cbffc5da98f91d171e74a
<!--
A soft-limit counter for the footer of a text field: a small ring that fills toward the limit
beside the count in words. Near the limit both turn to the warning colour; past it the ring
closes into an error mark and the text states the excess. It never truncates or blocks typing,
and the field describes itself with the counter's id. Screen readers hear a debounced polite
update only once the text is near the limit, never on every keystroke and never on load.
-->
<script lang="ts" module>
export type CharacterCounterMode = 'remaining' | 'used';
export type CharacterCounterCountBy = 'grapheme' | 'code-unit' | 'word';
export type CharacterCounterAlignment = 'start' | 'end';
export type CharacterCounterState = 'under' | 'near' | 'at' | 'over';
export interface CharacterCounterMessages {
/** Remaining mode: n units left, for example '38 characters remaining'. */
remaining?: (n: number) => string;
/** Used mode: count against the limit, for example '42 of 280 characters'. */
used?: (count: number, limit: number) => string;
/** Either mode, past the limit: n units too many, for example '3 characters too many'. */
over?: (n: number) => string;
}
let segmenter: Intl.Segmenter | null | undefined;
/**
* Counts text the way the counter does, so a submit handler or the server check can agree with
* it. Graphemes use Intl.Segmenter (a family emoji is one) and fall back to code points; words
* are runs of non-space characters, which is approximate for scripts written without spaces.
*/
export function countText(text: string, countBy: CharacterCounterCountBy = 'grapheme'): number {
if (!text) return 0;
if (countBy === 'code-unit') return text.length;
if (countBy === 'word') return text.match(/\S+/g)?.length ?? 0;
if (segmenter === undefined) {
segmenter =
typeof Intl !== 'undefined' && 'Segmenter' in Intl
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
: null;
}
return segmenter ? [...segmenter.segment(text)].length : [...text].length;
}
</script>
<script lang="ts">
interface Props {
/** The text to count: the field's current value. */
value: string;
/** The configured maximum, in the unit countBy names. Rounded down; anything but a positive number reads as 0. */
limit: number;
/** Id of the counter text, for the field's aria-describedby. Defaults to one per instance. */
id?: string;
/** 'remaining' shows '38 characters remaining'; 'used' shows '42 of 280 characters'. */
mode?: CharacterCounterMode;
/** What one unit is: a grapheme (what a person sees as a character), a UTF-16 code unit, or a word. */
countBy?: CharacterCounterCountBy;
/** Warn and start announcing once this many units or fewer remain. */
announceThreshold?: number;
/** Message builders, for other languages or other wording. */
messages?: CharacterCounterMessages;
/** Which edge of the field's footer the counter sits on. */
alignment?: CharacterCounterAlignment;
/** Bindable, read-only: true while the text is over the limit, for blocking submit. */
over?: boolean;
/** Extra classes on the root, for placement. */
class?: string;
}
let {
value,
limit,
id,
mode = 'remaining',
countBy = 'grapheme',
announceThreshold = 20,
messages,
alignment = 'end',
over = $bindable(false),
class: className
}: Props = $props();
const uid = $props.id();
const countId = $derived(id ?? `${uid}-count`);
const plural = (n: number) =>
countBy === 'word' ? (n === 1 ? 'word' : 'words') : n === 1 ? 'character' : 'characters';
const format = (n: number) => n.toLocaleString('en-US');
/* NaN, Infinity, negatives and fractions would print nonsense, so the limit is a whole number. */
const max = $derived(Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : 0);
const count = $derived(countText(value ?? '', countBy));
const remaining = $derived(max - count);
const status: CharacterCounterState = $derived(
remaining < 0
? 'over'
: remaining === 0
? 'at'
: remaining <= announceThreshold
? 'near'
: 'under'
);
/* Ring progress out of 100; a limit of zero reads as full. */
const progress = $derived(max > 0 ? Math.min(100, (count / max) * 100) : 100);
const text = $derived.by(() => {
if (remaining < 0)
return messages?.over?.(-remaining) ?? `${format(-remaining)} ${plural(-remaining)} too many`;
if (mode === 'used')
return messages?.used?.(count, max) ?? `${format(count)} of ${format(max)} ${plural(max)}`;
return (
messages?.remaining?.(remaining) ?? `${format(remaining)} ${plural(remaining)} remaining`
);
});
$effect.pre(() => {
const isOver = remaining < 0;
if (over !== isOver) over = isOver;
});
/*
* The visible text updates on every keystroke. The live region gets a copy only after typing has
* paused for a second and only within the threshold, as GOV.UK's character count does, and it
* stays empty on load so focusing the page does not read it. Only an edit to the value schedules
* one, and every edit restarts the pause, even one that leaves the count unchanged.
*/
let announcement = $state('');
let lastValue: string | undefined;
$effect(() => {
const current = value ?? '';
const message = text;
const edited = lastValue !== undefined && current !== lastValue;
lastValue = current;
if (remaining > announceThreshold) {
announcement = '';
return;
}
if (!edited) return;
const timer = setTimeout(() => (announcement = message), 1000);
return () => clearTimeout(timer);
});
</script>
<div
class={[
'character-counter flex min-w-0',
alignment === 'end' ? 'justify-end' : 'justify-start',
className
]}
data-state={status}
>
<p
id={countId}
class={[
'flex min-w-0 items-start gap-2 text-start text-[13px] leading-5 text-pretty break-words tabular-nums',
status === 'under' && 'text-(--_muted)',
(status === 'near' || status === 'at') && 'text-(--_warning)',
status === 'over' && 'font-medium text-(--_danger)'
]}
>
<!-- The ring repeats the text as a shape, so it is hidden; it closes into an error mark when over. -->
<span class="grid h-lh shrink-0 place-items-center" aria-hidden="true">
<!-- The ring starts at twelve o'clock and runs with the reading direction. -->
<svg class="character-counter__ring size-4 rtl:-scale-x-100" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="6.25" stroke="var(--_hairline)" stroke-width="1.75" />
{#if progress > 0}
<circle
class={[
'character-counter__progress origin-center -rotate-90',
status === 'under' ? 'stroke-(--_accent)' : 'stroke-current'
]}
cx="8"
cy="8"
r="6.25"
stroke-width="1.75"
stroke-linecap={progress < 100 ? 'round' : undefined}
pathLength="100"
stroke-dasharray={progress < 100 ? `${progress} 100` : undefined}
/>
{/if}
{#if status === 'over'}
<path d="M8 4.75v3.5" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" />
<circle cx="8" cy="11" r="1" fill="currentColor" />
{/if}
</svg>
</span>
<span class="min-w-0">{text}</span>
</p>
<span class="sr-only" aria-live="polite" aria-atomic="true">{announcement}</span>
</div>
<style>
/* Public tokens: set --character-counter-* on the counter or any ancestor to retone it. */
.character-counter {
--_accent: var(--character-counter-accent, #1d4ed8);
--_muted: var(--character-counter-muted, #52525b);
/* A meter track rather than a boundary, so a step firmer than a hairline: an empty ring still reads. */
--_hairline: var(--character-counter-hairline, rgb(0 0 0 / 0.16));
/* amber-700 and red-700: 5.0:1 and 6.5:1 on white, so the text clears 4.5:1. */
--_warning: var(--character-counter-warning, #b45309);
--_danger: var(--character-counter-danger, #b91c1c);
}
/* Forced colours: two elements change, the track to GrayText and the arc to the text colour. */
@media (forced-colors: active) {
.character-counter__ring circle:first-child {
stroke: GrayText;
}
.character-counter__progress {
stroke: currentColor;
}
}
</style>
Usage#
On this pagePass the field's value and a limit, put the counter under the field, and list its id in the field's aria-describedby. It renders the count on the server and updates it as the value changes. It never truncates or blocks input, never adds maxlength and never sets aria-invalid on your field: read the bindable over flag (or call countText) to block submit, and enforce the limit on the server too.
- Suggested location
src/lib/components/character-counter-01
Limitations
- It only counts and reports. Your form decides whether going over blocks submit, and the server has to enforce the limit itself, counting the same unit (
countTextshows how). - Word counting splits on whitespace, so it is approximate for Chinese, Japanese, Thai and other scripts written without spaces.
- Grapheme counting needs Intl.Segmenter (every current browser and Node 16 and later). Without it the counter counts code points, so a family emoji counts as seven, and a browser without it can show a different count from the server-rendered one after hydration.
- The default messages are English. Pass messages for other languages; the builders receive plain numbers, so format and pluralise them for the language.
- The bindable over flag is set in the browser. On the server it stays at its initial value, so call
countTextfor a server-side check. - The warning threshold and the announcement threshold are the same value,
announceThreshold.
Example
<script lang="ts">
import CharacterCounter from '$lib/components/character-counter-01/CharacterCounter.svelte';
let bio = $state('');
let over = $state(false);
let submitted = $state(false);
</script>
<form
method="POST"
class="grid max-w-md gap-1.5"
onsubmit={(event) => {
submitted = true;
if (over) event.preventDefault();
}}
>
<label for="bio" class="text-sm font-medium text-zinc-900">Bio</label>
<textarea
id="bio"
name="bio"
rows="4"
class="rounded-lg border border-zinc-300 px-3 py-2 text-sm"
aria-describedby="bio-count"
aria-invalid={submitted && over ? 'true' : undefined}
bind:value={bio}
></textarea>
<CharacterCounter id="bio-count" value={bio} limit={280} bind:over />
</form>The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.
Character counter#
The counter only counts. It sits under a field you own, and that field lists the counter's id in
its aria-describedby. Going over never blocks typing; your form decides what happens next.
Wiring it to a field#
<script lang="ts">
import CharacterCounter from '$lib/components/character-counter-01/CharacterCounter.svelte';
let bio = $state('');
let over = $state(false);
</script>
<label for="bio">Bio</label>
<textarea id="bio" name="bio" aria-describedby="bio-hint bio-count" bind:value={bio}></textarea>
<div class="flex items-start gap-4">
<p id="bio-hint" class="text-[13px] text-zinc-600">Shown on your profile.</p>
<CharacterCounter id="bio-count" value={bio} limit={280} bind:over class="ms-auto" />
</div>Do not add maxlength to the field. It cuts pasted text off without saying so, which is the
thing a soft limit exists to avoid.
Blocking submit and checking on the server#
over is true while the text is over the limit. Prevent the submit on it, and set
aria-invalid on your field once the person tries to submit, rather than while they are still
typing.
The server must enforce the limit itself. countText is exported from the component's module
script so both sides count the same unit:
import { countText } from '$lib/components/character-counter-01/CharacterCounter.svelte';
if (countText(form.get('bio') as string, 'grapheme') > 280) {
// reject
}Count by 'code-unit' when the limit really is String.length, such as a VARCHAR measured in
UTF-16, and by 'grapheme' when it is what a person would call a character. The two differ for
emoji and some scripts: a family emoji is one grapheme and eleven code units.
Announcements#
Sighted people see the count change on every keystroke. Screen reader users hear a polite update a
second after they stop typing, and only once announceThreshold (default 20) or fewer units
remain. The live region is empty on load, so nothing is read when the page opens. Focusing the
field reads the current count through aria-describedby at any time.
Other languages#
Pass messages. Each builder receives plain numbers, so format and pluralise them for the
language:
const messages = {
remaining: (n: number) => (n === 1 ? 'נותר תו אחד' : `נותרו ${n} תווים`),
over: (n: number) => (n === 1 ? 'תו אחד יותר מדי' : `${n} תווים יותר מדי`),
used: (count: number, limit: number) => `${count} מתוך ${limit} תווים`
};Word counting splits on whitespace and is approximate for languages written without spaces.
Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
value | string | Yes | None | The text to count, normally the field's bound value. |
limit | number | Yes | None | The configured maximum, in the unit countBy names. Fractions round down; zero, negatives, NaN and Infinity all read as a limit of 0. |
id | string | No | None | Id of the counter text. List it in the field's aria-describedby. Defaults to one generated per instance. |
mode | 'remaining' | 'used' | No | 'remaining' | 'remaining' shows '92 characters remaining'; 'used' shows '42 of 280 characters'. Both show '3 characters too many' past the limit. |
countBy | 'grapheme' | 'code-unit' | 'word' | No | 'grapheme' | The counting unit. 'grapheme' counts what a person sees as one character (Intl.Segmenter, falling back to code points); 'code-unit' matches String.length and most database limits; 'word' counts runs of non-space characters. |
announceThreshold | number | No | 20 | Once this many units or fewer remain, the counter turns to the warning colour and screen readers hear a polite update a second after typing pauses. |
messages | CharacterCounterMessages | No | None | Message builders for other languages or wording: { remaining?: (n) => string; used?: (count, limit) => string; over?: (n) => string }. Each one you leave out uses the English default. |
alignment | 'start' | 'end' | No | 'end' | Which edge of the field's footer the counter sits on. 'end' suits a footer with a hint on the start edge. |
over | boolean | No | false | Bindable and read-only: true while the text is over the limit. Use it to block submit or set aria-invalid on your field. |
class | string | No | None | Extra classes on the root, for placement. |
Customization#
On this pageChange the wording through messages and retone the counter through five --character-counter-* CSS variables. The ring takes the accent below the threshold and the text's colour above it.
- Accent:
--character-counter-accentfills the ring while the text is well under the limit. The default is near-black; set it to your brand colour to tie the ring to your focus ring. - Warning and danger:
--character-counter-warningcolours the text and ring near and at the limit (defaultamber-700, 5.0:1 on white);--character-counter-dangercolours them past it (defaultred-700, 6.5:1). Keep both at 4.5:1 against the page, since they colour text. - Neutrals:
--character-counter-mutedis the text below the threshold;--character-counter-hairlineis the ring's empty track. - Worked retone for a
#09090bband:--character-counter-accent:#fafafa;--character-counter-muted:#a1a1aa;--character-counter-hairline: rgb(255 255 255 / 0.16);--character-counter-warning:#fbbf24;--character-counter-danger:#f87171. - Cream page such as a bakery's
#fbf7f0: leave the hairline alone (it is alpha black, so it tints with the page) and keep the warning and danger defaults, which still clear 4.5:1. - Placement: put the counter in a flex row under the field with the hint on the start edge, and give the counter margin-inline-start: auto, or set alignment 'start' when there is no hint.
- Wording: pass messages for another language, or for sentence-style wording such as 'You have 12 characters left'. A Hebrew example: remaining: (n) => `נותרו $
{n}תווים`. - Unit: count by 'code-unit' when the server or database limit is String.length, and by 'word' for statements and applications. Count the same way on the server with
countText.
Public CSS variables
| Variable | Token |
|---|---|
--character-counter-accent | accent |
--character-counter-muted | muted |
--character-counter-hairline | hairline |
--character-counter-warning | warning |
--character-counter-danger | danger |
Accessibility#
On this page- List the counter's id in your field's
aria-describedby, so focusing the field reads the current count. The counter text is not a live region, so the description is never read twice. - A separate visually hidden polite live region (
aria-atomic) receives a copy of the text a second after typing pauses, and only onceannounceThresholdor fewer units remain. It is empty on load and while the text is well under the limit. - Native maxlength is never used, so pasted text is kept whole and people can go over and then edit down.
- Over the limit the text states the excess ('3 characters too many'), changes to the danger colour and weight 500, and the ring becomes an error mark: never colour alone.
- The counter does not set
aria-invalidon your field. Set it yourself when your form treats going over as an error, ideally on submit or blur rather than mid-typing. - The ring is decorative and hidden from assistive technology; the text carries the same information. In forced-colours mode the track and arc take system colours.
- Warning and danger text default to
amber-700andred-700, 5.0:1 and 6.5:1 on white; keep any retone at 4.5:1.
Known limitations
- Screen readers hear the count only within the threshold. Someone who wants it earlier can move focus back to the field, which reads the description.
Release details#
On this page- Integration
- Local interaction
- Requires client-side JavaScript to be interactive
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 22 October 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.