Typing Indicator
stimeo--typing-indicator
"X is typing…" over Action Cable — realtime with almost zero JS of your own.
The stimeo--typing-indicator controller is the first server-bound part: the state lives on the server's Action Cable stream, not in client memory. Typing anywhere inside the element broadcasts a throttled typing signal (leading edge, once per throttle ms) with this client's display name; signals received from OTHER clients are tracked per typist and rendered into the status live region ("X is typing…"), each auto-clearing after timeout ms of silence. Your own echoes are suppressed by comparing the name value. The server side is one thin channel that rebroadcasts the signal to the room — no state, no timers (this catalog's TypingChannel is the spec's sample plus a fixed-room allowlist for the public deploy). The announcement is localizable via the data-one/data-many templates, and the data-typing hook drives the visual treatment. Ships in the opt-in stimeo-ui/cable subpath (@rails/actioncable is an optional peer; the core stays zero-dependency), and reuses your app's existing consumer via setCableConsumer(). Authentication and the message body itself stay out of scope.
Open this page in a second tab side by side, then type in one composer: the other tab shows the indicator (your own tab stays quiet — own echoes are suppressed by design) and clears it about 3 seconds after you stop.
You appear to others as Guest cbb1.
<%# typing-indicator: the state lives on the server stream, not in client memory.
Typing in the composer broadcasts a throttled signal through TypingChannel;
every OTHER client shows "X is typing…" in the status live region and clears
it automatically after silence. Own echoes are suppressed by design, so open
this page in a second tab to see the indicator. The guest name is randomized
per render so each tab is a distinct "user" (a real app passes
current_user.name). The data-one/data-many templates keep the announcement
localizable; %{name}/%{names} are substituted by the controller. %>
<% guest = t("components.typing_indicator.demo.guest_name", token: SecureRandom.hex(2)) %>
<div class="typing-demo"
data-controller="stimeo--typing-indicator"
data-stimeo--typing-indicator-channel-value="TypingChannel"
data-stimeo--typing-indicator-params-value='{"room":"<%= TypingChannel::ROOM %>"}'
data-stimeo--typing-indicator-name-value="<%= guest %>"
data-stimeo--typing-indicator-timeout-value="3000"
data-stimeo--typing-indicator-throttle-value="1000">
<p class="typing-demo__hint"><%= t("components.typing_indicator.demo.hint") %></p>
<p class="typing-demo__you"><%= t("components.typing_indicator.demo.you_are", name: guest) %></p>
<label class="typing-demo__label" for="typing-demo-input">
<%= t("components.typing_indicator.demo.input_label") %>
</label>
<textarea id="typing-demo-input" class="demo-input typing-demo__input" rows="3"
data-stimeo--typing-indicator-target="input"></textarea>
<p class="typing-demo__status" role="status"
data-stimeo--typing-indicator-target="status"
data-one="<%= t("components.typing_indicator.demo.one") %>"
data-many="<%= t("components.typing_indicator.demo.many") %>"></p>
</div>
/*
* Presentation-only styles for the typing-indicator demo. The library fills the
* status live-region text and flips data-typing on the root; this CSS lays out
* the composer and makes the "someone is typing" state visible (animated dots
* are decorative — the announcement itself is the status text, per WCAG 4.1.3).
*/
.typing-demo {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: 28rem;
}
.typing-demo__hint,
.typing-demo__you {
margin: 0;
font-size: 0.9rem;
color: var(--muted);
}
.typing-demo__label {
font-size: 0.9rem;
}
.typing-demo__input {
resize: vertical;
}
.typing-demo__status {
margin: 0;
min-height: 1.25rem;
font-size: 0.9rem;
color: var(--muted);
}
/* While others are typing (data-typing="true" on the root), accent the status
line and append decorative pulsing dots after the announced text. */
.typing-demo[data-typing="true"] .typing-demo__status {
color: var(--accent);
}
.typing-demo[data-typing="true"] .typing-demo__status::after {
content: "…";
animation: typing-demo-pulse 1s ease-in-out infinite;
}
@keyframes typing-demo-pulse {
0%,
100% {
opacity: 0.2;
}
50% {
opacity: 1;
}
}
/* Respect reduced-motion preferences: keep the dots static. */
@media (prefers-reduced-motion: reduce) {
.typing-demo[data-typing="true"] .typing-demo__status::after {
animation: none;
}
}
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--typing-indicator"
Targets
| Name | Description | Attribute |
|---|---|---|
input
|
Optional composer marker. Typing is caught by a delegated input listener on the element, so any descendant input fires even without this target. |
data-stimeo--typing-indicator-target="input" |
status
|
The live region showing who is typing. Author role="status" (or aria-live); localize via the data-one / data-many templates (%{name} / %{names} / %{count}). |
data-stimeo--typing-indicator-target="status" |
Values
| Name | Description | Attribute |
|---|---|---|
channel
|
The Action Cable channel class to subscribe to. Empty (default) disables both subscribing and sending. | data-stimeo--typing-indicator-channel-value |
params
|
Extra params mixed into the subscription identifier (e.g. {"room":"chat_42"}). |
data-stimeo--typing-indicator-params-value |
name
|
This client's display name. Sent with each signal; received signals with the same name are ignored as echoes. | data-stimeo--typing-indicator-name-value |
timeout
|
Silence (ms) after which a typist is cleared automatically (default 3000). Each signal restarts their timer. | data-stimeo--typing-indicator-timeout-value |
throttle
|
Minimum interval (ms) between sent signals, leading edge (default 2000). | data-stimeo--typing-indicator-throttle-value |
Events
| Name | Description | Event |
|---|---|---|
change
|
Fires with { names } whenever the set of active typists changes (added or auto-cleared). |
stimeo--typing-indicator: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-typing |
Controller element | "true" while at least one other client is typing, "false" once everyone stops (absent initially). The consumer CSS hook. |