Live Counter
stimeo--live-counter
A like/view counter that moves in every open tab — HTML plus one broadcast.
The stimeo--live-counter controller binds a number (likes, views, seats left) to an Action Cable stream so it moves in every subscribed client at once. A local increment action bumps the display optimistically and performs increment on the channel; the server owns the number, persists it, and broadcasts the result. Two wire shapes are reconciled: the recommended absolute { count } (naturally idempotent — this catalog's LikesChannel demonstrates it) and the differential { delta, by }, where a by matching this client's id value is skipped as the own echo (the optimistic bump already applied it). The DOM text is the single source of truth — the initial value is server-rendered, so Turbo cache restores and Morphs need no reconciliation. The step per trigger comes from the data-stimeo--live-counter-delta-param action param (default 1). Number formatting and persistence stay out of scope (subscribe to change if you need locale formatting). Ships in the opt-in stimeo-ui/cable subpath; the core stays zero-dependency.
Open this page in a second tab and click Like in either: the server-owned count converges in both. Your own click bumps immediately (optimistic), the broadcast confirms it with the absolute count.
0 likes
<%# live-counter: the count's single source of truth is the DOM text
(server-rendered below from the channel's demo counter) plus the
LikesChannel stream. Clicking Like bumps the number optimistically and
performs increment; the server adds it to its counter and broadcasts the
absolute { count }, so every subscribed tab converges — open a second tab
and click in either. The trigger target keeps the button disabled while an
increment would be dropped (before the subscription confirms, during an
outage, after a rejection). The client id is randomized per render purely
for the demo (a real app passes current_user.id). The room comes from the
channel's allowlisted constant so the subscription params and the
server-rendered initial value below can never drift (a real app passes its
record id, e.g. post.id). %>
<% room = LikesChannel::ROOM %>
<div class="live-counter-demo"
data-controller="stimeo--live-counter"
data-stimeo--live-counter-channel-value="LikesChannel"
data-stimeo--live-counter-params-value='{"room":"<%= room %>"}'
data-stimeo--live-counter-id-value="<%= SecureRandom.hex(4) %>">
<p class="live-counter-demo__hint"><%= t("components.live_counter.demo.hint") %></p>
<div class="live-counter-demo__row">
<button type="button" class="demo-trigger live-counter-demo__button"
data-stimeo--live-counter-target="trigger"
data-action="stimeo--live-counter#increment">
<svg class="demo-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M19 14c1.5-1.5 3-3.2 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.8 0-3 .5-4.5 2
-1.5-1.5-2.7-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4 3 5.5l7 7Z"/>
</svg>
<%= t("components.live_counter.demo.like") %>
</button>
<p class="live-counter-demo__count">
<%# Server-rendered initial value: reading the channel's in-memory demo
counter keeps the DOM truth aligned with the server (a real app
renders the persisted count, e.g. post.likes_count). %>
<span class="live-counter-demo__value" data-stimeo--live-counter-target="value"><%=
LikesChannel.count(room) %></span>
<span class="live-counter-demo__unit"><%= t("components.live_counter.demo.unit") %></span>
</p>
</div>
</div>
/*
* Presentation-only styles for the live-counter demo. The library only rewrites
* the value target's text (optimistic bump + broadcast reconciliation); this
* CSS lays out the like button and the count readout.
*/
.live-counter-demo {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 28rem;
}
.live-counter-demo__hint {
margin: 0;
font-size: 0.9rem;
color: var(--muted);
}
.live-counter-demo__row {
display: flex;
align-items: center;
gap: 1rem;
}
.live-counter-demo__button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.live-counter-demo__button .demo-icon {
width: 1rem;
height: 1rem;
}
.live-counter-demo__count {
margin: 0;
}
.live-counter-demo__value {
font-size: 1.25rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.live-counter-demo__unit {
margin-left: 0.25rem;
font-size: 0.9rem;
color: var(--muted);
}
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--live-counter"
Targets
| Name | Description | Attribute |
|---|---|---|
value
|
Optional display element for the count. Without it the controller element itself is the display. | data-stimeo--live-counter-target="value" |
trigger
|
data-stimeo--live-counter-target="trigger" |
Values
| Name | Description | Attribute |
|---|---|---|
channel
|
The Action Cable channel class to subscribe to. Empty (default) skips the subscription — only the optimistic bump remains. | data-stimeo--live-counter-channel-value |
params
|
Extra params mixed into the subscription identifier (e.g. {"room":"post_42"}). |
data-stimeo--live-counter-params-value |
id
|
This client's identifier, used to skip the own echo of differential { delta, by } broadcasts. |
data-stimeo--live-counter-id-value |
Actions
| Name | Description | Action |
|---|---|---|
increment
|
Optimistically bumps the display and performs increment on the channel. Step via data-stimeo--live-counter-delta-param (default 1). |
stimeo--live-counter#increment |
Events
| Name | Description | Event |
|---|---|---|
change
|
Fires with { count } whenever the displayed value actually changes (optimistic bump and reconciliation alike). |
stimeo--live-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 |
|---|---|---|
value target text |
value target (or the controller element) | The count itself — the DOM is the single source of truth (server-rendered initial value). |