Live Counter
stimeo--live-counter
A counter that moves in every open tab at once. The number itself belongs to the server.
Keeps a number such as likes or views moving in every open tab at once. The screen of whoever pressed it goes up immediately, and the server's own count arrives shortly after to settle it. Because the server owns the number, however many tabs are open they end on the same one. The server can report either the settled count or just the amount it went up by, and an increase you caused yourself is never added twice. The number on screen is the real count, so a Turbo page swap needs no reconciliation. How much one press adds is up to you. Formatting the number and storing it are not its job.
Open this page in a second tab and press Like in either one: both tabs land on the same number. The side you pressed goes up straight away, and the server's count settles it.
This tab is 45c5643f. The counter uses it to tell your own presses apart from the other tab's.
0 likes
Keyboard
This component has no keyboard interactions of its own.
<%# 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, which
identifies this tab rather than this user — a real app passes any per-client
string (SecureRandom.uuid). Sharing one id across tabs still converges, since
an echo only cancels one guess of its own size, but a per-tab id keeps every
echo paired with the tab that guessed it. It is printed below so two tabs
side by side can be told apart; an app has no reason to show it. 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 %>
<% tab = SecureRandom.hex(4) %>
<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="<%= tab %>">
<p class="live-counter-demo__hint"><%= t("components.live_counter.demo.hint") %></p>
<p class="live-counter-demo__you"><%=
t("components.live_counter.demo.this_tab", token: tab) %></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="click->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,
.live-counter-demo__you {
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 |
announceText
|
Wording sent to the shared announcer when a broadcast settles the count ({count}). Empty (default) announces nothing; an optimistic bump never announces. |
data-stimeo--live-counter-announce-text-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). |