Character Counter
stimeo--character-counter
Shows the characters left, flags the limit approaching and passed, and speaks once typing stops.
Watches a field's length and shows how many characters are left, or how many are used. Approaching the limit and passing it are each published for CSS, so you choose the colour and emphasis. A field over the limit is reported as invalid to screen readers too. The visible count updates immediately, while the spoken one arrives shortly after typing stops, so nothing is announced per keystroke. The wording that gets spoken can be written per language.
Keyboard
This component has no keyboard interactions of its own.
<%# Markup for the character-counter demo. The visible output updates on every
keystroke, while the settled remaining count is debounced into the shared
stimeo--announcer your app seats once, in its layout (the settled count is the
transition worth reading; the visible output is not itself a live region). This
demo's CSS styles the root's near/over hooks and the over-limit aria-invalid;
watching the field needs no consumer JavaScript. %>
<% announcement = t("components.character_counter.demo.announcement") %>
<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"
data-stimeo--character-counter-announce-text-value="<%= announcement %>">
<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"></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-700);
}
.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
|
Optional element whose text immediately shows the count; it is not a live region. | data-stimeo--character-counter-target="output" |
Values
| Name | Description | Attribute |
|---|---|---|
max
|
Finite non-negative integer maximum; 0 means no limit (used count only). Default 0. | data-stimeo--character-counter-max-value |
warnAt
|
Finite non-negative integer warning threshold; 0 disables it. Default 0. | data-stimeo--character-counter-warn-at-value |
mode
|
Display mode: remaining, used, or both; unknown values fall back to remaining. |
data-stimeo--character-counter-mode-value |
announceText
|
Optional shared-announcer template using {count}, {length}, {remaining}, {max}, and {over}. |
data-stimeo--character-counter-announce-text-value |
Events
| Name | Description | Event |
|---|---|---|
change
|
Fires after confirmed user input only when length changes, with detail.length / detail.remaining / detail.over. |
stimeo--character-counter:change |
reconcile
|
Fires once when runtime target/Value reconciliation changes the same derived detail; display-only changes stay silent. | stimeo--character-counter:reconcile |
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 immediately updated remaining/used count for the active mode. |