Character Counter
stimeo--character-counter
Shows the remaining/used character count and flags the over-limit state, announced politely.
The stimeo--character-counter controller watches a text field's length and writes the remaining or used count into an output element. It toggles the data-near-limit / data-over-limit state hooks and sets aria-invalid on the field once the limit is exceeded, and dispatches stimeo--character-counter:change with detail.length / detail.remaining / detail.over on every keystroke. The non-text state updates immediately, while the visible count — which lives in the aria-live="polite" region — is written on a short debounce so a screen reader is not flooded during fast typing (it hears the settled count when the user pauses). Behavior only — the count text is updated, not styled; the input listener and debounce timer are torn down on disconnect (Turbo included), and connect re-reads the field so the count survives a cache restore.
<%# Character counter demo: a textarea whose remaining count is shown beneath it.
The controller writes the count into the output (an aria-live="polite" region),
toggles data-near-limit / data-over-limit on the root, and sets aria-invalid on the
field past the max. This demo styles those state hooks; the library only updates the
count text and the hooks — no consumer JS is needed (it reacts to input on its own). %>
<div class="character-counter-demo">
<label class="character-counter-demo__label" for="cc-message">
<%= t("components.character_counter.demo.label") %>
</label>
<div
class="character-counter"
data-controller="stimeo--character-counter"
data-stimeo--character-counter-max-value="140"
data-stimeo--character-counter-warn-at-value="20">
<textarea
id="cc-message"
class="character-counter__input"
rows="3"
data-stimeo--character-counter-target="input"
aria-describedby="cc-message-count"
placeholder="<%= t('components.character_counter.demo.placeholder') %>"></textarea>
<span
id="cc-message-count"
class="character-counter__output"
data-stimeo--character-counter-target="output"
aria-live="polite"></span>
</div>
</div>
/*
* Presentation-only styles for the character counter demo.
* The library updates the count text and toggles data-near-limit / data-over-limit on
* the root (and aria-invalid on the field); this CSS reacts to those hooks.
*/
.character-counter-demo {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: 32rem;
}
.character-counter-demo__label {
font-weight: 600;
color: var(--fg);
}
.character-counter {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.character-counter__input {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
font: inherit;
resize: vertical;
}
.character-counter__output {
align-self: flex-end;
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
color: var(--color-text-muted);
}
/* Warn as the remaining count gets low, then signal the over-limit state. */
.character-counter[data-near-limit] .character-counter__output {
color: var(--accent);
}
.character-counter[data-over-limit] .character-counter__output {
color: var(--danger-500);
font-weight: 600;
}
.character-counter__input[aria-invalid="true"] {
border-color: var(--danger-500);
}
This demo needs no consumer-side JS (the controller handles the behavior).
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--character-counter"
Targets
| Name | Description | Attribute |
|---|---|---|
input
|
The watched field; falls back to the controller element when it is an input/textarea. |
data-stimeo--character-counter-target="input" |
output
|
Element whose text shows the count; should be an aria-live="polite" region. |
data-stimeo--character-counter-target="output" |
Values
| Name | Description | Attribute |
|---|---|---|
max
|
Maximum length; 0 means no limit (used count only). Default 0. | data-stimeo--character-counter-max-value |
warnAt
|
Remaining count at/below which data-near-limit is set; 0 disables it. Default 0. |
data-stimeo--character-counter-warn-at-value |
mode
|
Display mode: remaining, used, or both. Default remaining. |
data-stimeo--character-counter-mode-value |
Events
| Name | Description | Event |
|---|---|---|
change
|
Fires on every input with detail.length / detail.remaining / detail.over. |
stimeo--character-counter:change |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
data-over-limit |
Root element | Present once the value exceeds max. |
data-near-limit |
Root element | Present when the remaining count is at/below warnAt and not yet over. |
aria-invalid |
Input field | Set to "true" while the value exceeds max. |
text content |
Output element | The remaining/used count for the active mode (the live region). |