Presence
stimeo--presence
A list of who is looking at this page right now, with no presence stored on the server.
Lists who is looking at this page right now, while your server stores nothing about it. Each browser says "still here" at a steady interval, and anyone who has gone quiet for long enough is dropped by everyone independently. Closing a tab or leaving the page announces the departure, and if that announcement never arrives the timeout removes them anyway, so nobody stays listed after leaving. When someone new arrives, those already there say their name again, so everyone converges quickly. You never appear in your own list. Showing a count and showing the names are both optional.
Open this page in a second tab: the other visitor joins the list after a moment, though you never see yourself. Close that tab and it disappears, and if the goodbye is lost the timeout removes it anyway.
You appear to others as Guest 3388.
Keyboard
This component has no keyboard interactions of its own.
<%# 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 |
announceJoinText
|
Wording sent to the shared announcer when a peer joins ({name} / {count}). Empty (default) announces nothing. |
data-stimeo--presence-announce-join-text-value |
announceLeaveText
|
Wording sent to the shared announcer when a peer leaves ({name} / {count}). Empty (default) announces nothing. |
data-stimeo--presence-announce-leave-text-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). |