# 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.

- ID: `cmp_character_counter_01`
- Slug: `character-counter-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-10-22T05:04:49Z
- Updated: 2026-10-22
- Available versions: `1.0.0`
- Kind: control
- Primary category: `text-inputs`
- Detail page: https://pagesugar.com/components/character-counter-01
- Preview: https://pagesugar.com/preview/character-counter-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-a47f5c42df505998a8d6b36a1e93b1ec80b72361979addd35025ea496fa0a45d` |
| `blue` | Blue accent | no | `sha256-4b81a165a26952ee6c99daa9ef692c4fc0d0a2961d7cbffc5da98f91d171e74a` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Imports: 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.
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: yes
- Integration level: local-interaction
- Appearance modes: light
- Suggested directory: `src/lib/components/character-counter-01`

## Dependencies

No third-party runtime packages.

## Services

No external services required.

## Usage

Pass 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.

Required props: `value`, `limit`

```svelte
<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>
```

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 (countText shows 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 countText for a server-side check.
- The warning threshold and the announcement threshold are the same value, announceThreshold.

## Usage guide

### 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

```svelte
<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:

```ts
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:

```ts
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

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `value` | `string` | yes |  | The text to count, normally the field's bound value. |
| `limit` | `number` | yes |  | 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 |  | 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 |  | 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 |  | Extra classes on the root, for placement. |

## Customization

Change 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-accent fills 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-warning colours the text and ring near and at the limit (default amber-700, 5.0:1 on white); --character-counter-danger colours them past it (default red-700, 6.5:1). Keep both at 4.5:1 against the page, since they colour text.
- Neutrals: --character-counter-muted is the text below the threshold; --character-counter-hairline is the ring's empty track.
- Worked retone for a #09090b band: --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.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--character-counter-accent` |
| `muted` | `--character-counter-muted` |
| `hairline` | `--character-counter-hairline` |
| `warning` | `--character-counter-warning` |
| `danger` | `--character-counter-danger` |

## Accessibility

- 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 once announceThreshold or 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-invalid on 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-700 and red-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.

## License

- Declared source: MIT
- Default license approval is pending. See https://pagesugar.com/docs/license.

## Source

- Palette: Neutral (`neutral`)
- Entry: `CharacterCounter.svelte`
- Suggested directory: `src/lib/components/character-counter-01`
- Files: 1
- Artifact digest: `sha256-a47f5c42df505998a8d6b36a1e93b1ec80b72361979addd35025ea496fa0a45d`

Paths below are relative to the suggested directory. Copy the files as they are;
they import nothing from this site.

#### `CharacterCounter.svelte`

Role: entry · 8117 bytes · SHA-256 `5af245e9f1383dea2a1e4b5ea2c7331d9b29f067347e56bd38b7e9fc4c10e538`

```svelte
<!--
	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, #18181b);
		--_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>
```

## Artifacts

### Neutral (`neutral`) (default)

- Artifact digest: `sha256-a47f5c42df505998a8d6b36a1e93b1ec80b72361979addd35025ea496fa0a45d`
- Entry: `CharacterCounter.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_character_counter_01/1.0.0/neutral/sha256-a47f5c42df505998a8d6b36a1e93b1ec80b72361979addd35025ea496fa0a45d/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_character_counter_01/1.0.0/neutral/sha256-a47f5c42df505998a8d6b36a1e93b1ec80b72361979addd35025ea496fa0a45d/bundle.zip (6743 bytes, sha256 `d7efd4baea2be5e217f65843965116ba112227deecb5b68191595fee58eadcc5`)

Files:

- `CharacterCounter.svelte` (entry, 8117 bytes): https://pagesugar.com/artifacts/cmp_character_counter_01/1.0.0/neutral/sha256-a47f5c42df505998a8d6b36a1e93b1ec80b72361979addd35025ea496fa0a45d/source/CharacterCounter.svelte

### Blue accent (`blue`)

- Artifact digest: `sha256-4b81a165a26952ee6c99daa9ef692c4fc0d0a2961d7cbffc5da98f91d171e74a`
- Entry: `CharacterCounter.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_character_counter_01/1.0.0/blue/sha256-4b81a165a26952ee6c99daa9ef692c4fc0d0a2961d7cbffc5da98f91d171e74a/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_character_counter_01/1.0.0/blue/sha256-4b81a165a26952ee6c99daa9ef692c4fc0d0a2961d7cbffc5da98f91d171e74a/bundle.zip (6739 bytes, sha256 `b95a6b2535346dec6ddafc56b54b6c7a701b05c8745bc9829b0da315bb6a9b22`)

Files:

- `CharacterCounter.svelte` (entry, 8117 bytes): https://pagesugar.com/artifacts/cmp_character_counter_01/1.0.0/blue/sha256-4b81a165a26952ee6c99daa9ef692c4fc0d0a2961d7cbffc5da98f91d171e74a/source/CharacterCounter.svelte
