Typing Indicator
stimeo--typing-indicator
Shows "someone is typing…" with almost no JavaScript of your own.
Shows the other person that someone is typing. The state lives on the server's connection rather than in any browser. Typing inside this element sends a signal carrying your display name, spaced out so it is not sent on every keystroke. Signals from other people are tracked per typist and clear themselves once someone has been quiet for a while. Your own signal never appears on your own screen. On the server, all that is needed is passing the signal back to the room: no state and no timers. The wording can be written per language.
Open this page in a second tab side by side and type in one of them: the other shows the indicator, while your own tab stays quiet. It clears a few seconds after you stop.
You appear to others as Guest 204f.
Keyboard
This component has no keyboard interactions of its own.
<%# 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 visible status slot 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. The status slot is
plain visible copy — assistive tech is reached through the page's shared announcer
instead, so the announce-one/announce-many values carry the spoken wording ({name}
syntax, not %{}). Leaving them off shows the indicator without announcing it. %>
<% guest = t("components.typing_indicator.demo.guest_name", token: SecureRandom.hex(2)) %>
<% announce_one = t("components.typing_indicator.demo.announce_one") %>
<% announce_many = t("components.typing_indicator.demo.announce_many") %>
<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"
data-stimeo--typing-indicator-announce-one-text-value="<%= announce_one %>"
data-stimeo--typing-indicator-announce-many-text-value="<%= announce_many %>">
<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"
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-700);
}
.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 visible slot showing who is typing (not a live region). Localize via the data-one / data-many templates (%{name} / %{names} / %{count}); the shared announcer does the reading. |
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 |
announceOneText
|
Wording sent to the shared announcer while one peer types ({name}). Empty (default) announces nothing. |
data-stimeo--typing-indicator-announce-one-text-value |
announceManyText
|
Wording sent to the shared announcer while several peers type ({names} / {count}). Empty (default) announces nothing. |
data-stimeo--typing-indicator-announce-many-text-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. |