Character Count
Free · MITHeadless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.
Install
Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).
Unstyled — behavior only, you write the CSS
npx astrocraft-ui add forms/character-countPlain-CSS theme — no build step
npx astrocraft-ui add forms/character-count --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add forms/character-count --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/character-count --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/character-count --theme tailwind --bridge lumosLive demo
Submitting: error summary, autosize, character count
Submit it empty: the summary appears, takes focus, and links to the first field that failed. Fill it in and the button goes aria-busy exactly once. The bio grows as you type and the counter only speaks up near the limit.
Attribute surface
With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.
| Component | Slots | Variants | Runtime state | Native state |
|---|---|---|---|---|
| `CharacterCount.astro` | `character-count` `character-count-status` `character-count-value` | — | `data-state` | — |
Source
What the command copies — 3 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.
---
// src/components/ui/forms/character-count/CharacterCount.astro — headless primitive (see ../../README.md).
// A live character count for the field named by `for` (the input or textarea id, mirroring <label for>).
//
// It renders TWO numbers on purpose, and that split is the whole component:
// • the visible one updates on every keystroke and is `aria-hidden`;
// • the live region is visually hidden, debounced, and only speaks when the count is news — near
// the limit or past it (see count-state.ts). A counter wired straight to a polite live region
// reads out every keystroke and makes the field unusable with a screen reader.
//
// `max` falls back to the field's own `maxlength`, so a field that already caps its length needs no
// second number. When the limit is passed, the root takes the shared `data-state="error"`.
// Without JS it renders a static zero — no error, just no count.
import "../../../../styles/structure.css";
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"p"> & { for: string; max?: number; warnAt?: number };
const { for: target, max, warnAt = 20, class: className, ...rest } = Astro.props;
---
<p
class={className}
data-slot="character-count"
data-state="default"
data-for={target}
data-max={max ? String(max) : undefined}
data-warn-at={String(warnAt)}
{...rest}
>
<span data-slot="character-count-value" aria-hidden="true">{max ? `0 / ${max}` : "0"}</span>
<span data-slot="character-count-status" aria-live="polite"></span>
</p>
<script>
import { onReadyOnce } from "../../_once";
import { countState } from "./count-state";
// Long enough that a steady typist is not interrupted mid-word, short enough to land before they
// look away. ponytail: one fixed delay for every instance — make it a prop if a consumer needs
// a different pace.
const SETTLE_MS = 700;
function wire(root: HTMLElement) {
const field = document.getElementById(root.dataset.for ?? "");
const value = root.querySelector<HTMLElement>('[data-slot="character-count-value"]');
const status = root.querySelector<HTMLElement>('[data-slot="character-count-status"]');
if (!(field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement)) return;
if (!value || !status) return;
const max = Number(root.dataset.max) || (field.maxLength > 0 ? field.maxLength : 0);
const warnAt = Number(root.dataset.warnAt) || 20;
let settle: ReturnType<typeof setTimeout>;
const paint = () => {
const length = field.value.length;
const { remaining, state, announce } = countState(length, max, warnAt);
value.textContent = max > 0 ? `${length} / ${max}` : String(length);
root.dataset.state = state;
clearTimeout(settle);
settle = setTimeout(() => {
status.textContent = !announce
? ""
: remaining < 0
? `${-remaining} characters over the limit`
: `${remaining} characters remaining`;
}, SETTLE_MS);
};
field.addEventListener("input", paint);
paint();
}
onReadyOnce('[data-slot="character-count"]', wire);
</script>
// src/components/ui/forms/character-count/count-state.ts — the counting and announcement rule behind
// CharacterCount, in a plain module so it is unit-checkable (see count-state.test.ts) without a DOM.
export interface CountState {
/** Characters left before the limit — negative once it has been passed. */
readonly remaining: number;
/** The shared field `data-state` value: `"error"` once the limit is exceeded. */
readonly state: "default" | "error";
/** Whether this count is worth interrupting a screen reader for. */
readonly announce: boolean;
}
/**
* Work out what a character counter should report.
*
* The `announce` flag is the point of this function. A live region that speaks every keystroke makes
* a textarea unusable with a screen reader — "sixty-three remaining, sixty-two remaining" over the
* top of the letters being typed. So the region stays silent until the count is actually news: near
* the limit, or past it. The visible number still updates on every keystroke; it is `aria-hidden`.
*
* @param length - how many characters are in the field
* @param max - the limit, or `0` for a counter with no limit
* @param warnAt - how many characters from the limit the announcements start
* @returns the {@link CountState} for this length
* @example countState(95, 100, 20) // => { remaining: 5, state: "default", announce: true }
*/
export function countState(length: number, max: number, warnAt = 20): CountState {
// No limit: a plain tally. There is nothing to be near, so there is nothing to announce.
if (max <= 0) return { remaining: 0, state: "default", announce: false };
const remaining = max - length;
return {
remaining,
state: remaining < 0 ? "error" : "default",
announce: remaining <= warnAt,
};
}
import CharacterCount from "./CharacterCount.astro";
export { type CountState, countState } from "./count-state";
export { CharacterCount };
export default CharacterCount;
What you get
The component source, copied into your project by npx astrocraft-ui add forms/character-count — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.