Presence
stimeo--presence
A who's-here roster over Action Cable — with zero server-side state.
The stimeo--presence controller binds a "who is viewing this page" roster to an Action Cable channel while the server keeps NO presence state: each client broadcasts an appear beacon ({ id, name }) on a heartbeat interval, every client expires peers it hasn't heard from within timeout ms, and leaving is announced best-effort on disconnect and on pagehide (tab close / hard navigation) ({ id, leaving: true }) — a lost notice is caught by the expiry. Newcomers converge in one round trip because clients that hear an unknown peer re-announce themselves (throttled). Your own beacons are ignored via the id value. Rendering is optional and declarative: the count target with its data-zero/-one/-other templates, and the list + template pair (one clone per peer, the name filled into the data-presence-name slot). The data-present and data-present-count hooks plus the join/leave/change events serve custom UIs. The server side is the same thin rebroadcast channel as typing-indicator (this catalog's PresenceChannel is the spec sample plus a fixed-room allowlist for the public deploy). Ships in the opt-in stimeo-ui/cable subpath; the core stays zero-dependency.
Open this page in a second tab: the other guest appears in the roster within a heartbeat (your own entry is not listed — own beacons are ignored). Close that tab or navigate it away to see the leave notice remove it; if the best-effort notice is lost, the timeout expiry catches it.
You appear to others as Guest 5657.
<%# presence: a who's-here roster with zero server state. Each client beacons
{ id, name } through PresenceChannel on a heartbeat; every client expires
silent peers locally and re-announces itself to newcomers, so the roster
converges in one round trip. Your own beacons are ignored (echo
suppression), so open this page in a second tab to see a peer appear — then
close it or navigate it away for the leave notice (sent best-effort on
disconnect and pagehide); a lost notice is caught by the timeout expiry. The id/name
pair is randomized per render, which is fine for a demo; a real app renders
current_user.id / current_user.name. The count templates (data-zero/-one/
-other) keep the text localizable; %{count} is substituted by the
controller. Heartbeat/timeout are shortened so leaving is quick to observe. %>
<% token = SecureRandom.hex(4) %>
<% guest = t("components.presence.demo.guest_name", token: token.first(4)) %>
<div class="presence-demo"
data-controller="stimeo--presence"
data-stimeo--presence-channel-value="PresenceChannel"
data-stimeo--presence-params-value='{"room":"<%= PresenceChannel::ROOM %>"}'
data-stimeo--presence-id-value="<%= token %>"
data-stimeo--presence-name-value="<%= guest %>"
data-stimeo--presence-heartbeat-value="5000"
data-stimeo--presence-timeout-value="12000">
<p class="presence-demo__hint"><%= t("components.presence.demo.hint") %></p>
<p class="presence-demo__you"><%= t("components.presence.demo.you_are", name: guest) %></p>
<p class="presence-demo__count"
data-stimeo--presence-target="count"
data-zero="<%= t("components.presence.demo.count_zero") %>"
data-one="<%= t("components.presence.demo.count_one") %>"
data-other="<%= t("components.presence.demo.count_other") %>"></p>
<ul class="presence-demo__list"
aria-label="<%= t("components.presence.demo.list_label") %>"
data-stimeo--presence-target="list"></ul>
<template data-stimeo--presence-target="template">
<li class="presence-demo__peer">
<span class="presence-demo__dot" aria-hidden="true"></span>
<span data-presence-name></span>
</li>
</template>
</div>
/*
* Presentation-only styles for the presence demo. The library owns the roster
* (count text, cloned list items, data-present/-count hooks); this CSS renders
* the online dots and the list. The dot is decorative color only — the peer's
* name text is the accessible representation (WCAG 1.4.1).
*/
.presence-demo {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: 28rem;
}
.presence-demo__hint,
.presence-demo__you {
margin: 0;
font-size: 0.9rem;
color: var(--muted);
}
.presence-demo__count {
margin: 0;
min-height: 1.25rem;
font-weight: 600;
}
.presence-demo__list {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin: 0;
padding: 0;
list-style: none;
min-height: 1.5rem;
}
.presence-demo__peer {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.6rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background: var(--bg);
font-size: 0.9rem;
}
/* The online dot: a small green disc next to the peer's name (theme-aware token). */
.presence-demo__dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--leaf-700);
}
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--presence"
Targets
| Name | Description | Attribute |
|---|---|---|
count
|
Optional headcount text. Localize via the data-zero / data-one / data-other templates (%{count}). |
data-stimeo--presence-target="count" |
list
|
Optional roster container: one template clone per peer is kept in it. |
data-stimeo--presence-target="list" |
template
|
The <template> cloned per peer; the peer's name fills the data-presence-name slot, the clone root gets data-presence-id. |
data-stimeo--presence-target="template" |
Values
| Name | Description | Attribute |
|---|---|---|
channel
|
The Action Cable channel class to subscribe to. Empty (default) disables the subscription. | data-stimeo--presence-channel-value |
params
|
Extra params mixed into the subscription identifier (e.g. {"room":"doc_7"}). |
data-stimeo--presence-params-value |
id
|
This client's identifier. Sent with each beacon; received beacons with the same id are ignored as echoes. Empty = observe-only (no beacons sent). |
data-stimeo--presence-id-value |
name
|
The display name carried by the beacons (rename is reflected on the next beacon). | data-stimeo--presence-name-value |
heartbeat
|
Beacon interval in ms (default 15000). | data-stimeo--presence-heartbeat-value |
timeout
|
A peer silent for this many ms is expired locally (default 40000; keep it above 2× heartbeat). |
data-stimeo--presence-timeout-value |
Events
| Name | Description | Event |
|---|---|---|
join
|
Fires with { id, name } when a previously unknown peer is heard. |
stimeo--presence:join |
leave
|
Fires with { id } when a peer leaves (leaving notice or expiry). |
stimeo--presence:leave |
change
|
Fires with { users } whenever the roster changes (join / leave / rename). |
stimeo--presence: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-present |
Controller element | "true" while at least one other person is here, "false" at zero (absent initially). |
data-present-count |
Controller element | The number of others present (excluding yourself), for attribute-selector or CSS-counter styling. |
data-presence-id |
Roster clone | Each rendered clone carries its peer's id (also the removal key). |