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, shown without its comments; the files you install keep them, with the reasoning behind every rule.

src/components/ui/forms/character-count/CharacterCount.astro
---
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";

  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
export interface CountState {
  readonly remaining: number;
  readonly state: "default" | "error";
  readonly announce: boolean;
}

export function countState(length: number, max: number, warnAt = 20): CountState {
  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.