Skip to main content
astrocraft-ui/ components · 101

Character Count

Free · MIT

Headless, 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

Install command
npx astrocraft-ui add forms/character-count

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add forms/character-count --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add forms/character-count --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/character-count --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/character-count --theme tailwind --bridge lumos

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

As it should appear on the invoice.

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.

ComponentSlotsVariantsRuntime stateNative 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
---
// 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
// 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,
  };
}
src/components/ui/forms/character-count/index.ts
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.