Empty State Observer
stimeo--empty-state
Shows an empty message only while a list has nothing in it, and hides it once something arrives.
Watches a list and swaps in the placeholder you prepared when it empties, swapping back once something arrives. On a page where Turbo Stream rows appear, that saves counting them yourself. Whether it is empty and how many items there are can be read from CSS, so the presentation is yours. Emptying and filling can each announce their own wording. The state is counted afresh from the list, so it keeps up when the page is swapped or the list itself is replaced. The artwork and the wording for the empty case belong to you.
- Item 1
- Item 2
Nothing here yet — add an item.
Keyboard
This component has no keyboard interactions of its own.
<%# Empty-state demo: add/remove items and the empty placeholder toggles itself. The
controller observes the list with a MutationObserver, toggles hidden on the
list / empty targets at the 0 <-> 1+ boundary, and reflects data-empty / data-count.
demo.js adds/removes <li> (standing in for Turbo Stream rows). This demo only styles
it; the empty placeholder's copy is owned here, and the crossing is read out — without
moving focus — through the shared stimeo--announcer your app seats once, in its layout
(a placeholder that only becomes visible at the moment of the change is not reliably
read, so the wording goes to a region that stands in the page independently of it). %>
<div
class="empty-state-demo"
data-controller="stimeo--empty-state"
data-stimeo--empty-state-announce-text-value="<%=
t("components.empty_state.demo.announce_empty") %>"
data-stimeo--empty-state-announce-filled-text-value="<%=
t("components.empty_state.demo.announce_filled") %>">
<div class="empty-state-demo__bar">
<button
type="button"
class="demo-trigger"
data-empty-state-add
data-item-label="<%= t("components.empty_state.demo.item") %>">
<%= t("components.empty_state.demo.add") %>
</button>
<button type="button" class="demo-trigger" data-empty-state-clear>
<%= t("components.empty_state.demo.clear") %>
</button>
</div>
<ul class="empty-state-demo__list" data-stimeo--empty-state-target="list">
<li><%= t("components.empty_state.demo.item") %> 1</li>
<li><%= t("components.empty_state.demo.item") %> 2</li>
</ul>
<p class="empty-state-demo__empty" data-stimeo--empty-state-target="empty" hidden>
<%= t("components.empty_state.demo.empty") %>
</p>
</div>
/*
* Presentation-only styles for the empty-state demo.
* The library toggles `hidden` on the list / empty targets and reflects
* data-empty / data-count; this CSS only lays out the list, items, and placeholder.
*/
.empty-state-demo {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 28rem;
}
.empty-state-demo__bar {
display: flex;
gap: 0.5rem;
}
.empty-state-demo__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.empty-state-demo__list li {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
background: var(--surface-subtle);
}
.empty-state-demo__empty {
margin: 0;
padding: 1.5rem;
border: 1px dashed var(--border);
border-radius: 0.5rem;
text-align: center;
color: var(--color-text-muted);
}
// Empty-state demo (consumer-side JS).
//
// The controller watches the list with a MutationObserver and toggles the empty
// placeholder on its own. This catalog has no Turbo Stream backend, so here the
// buttons add/remove <li> rows — exactly the kind of mutation Turbo Stream would
// make — and the controller reacts to them.
document.querySelectorAll(".empty-state-demo").forEach((root) => {
const list = root.querySelector('[data-stimeo--empty-state-target="list"]');
const add = root.querySelector("[data-empty-state-add]");
const label = add?.dataset.itemLabel;
if (!list) return;
let count = list.children.length;
add?.addEventListener("click", () => {
const li = document.createElement("li");
li.textContent = `${label ?? "Item"} ${(count += 1)}`;
list.appendChild(li);
});
root.querySelector("[data-empty-state-clear]")?.addEventListener("click", () => {
list.replaceChildren();
});
});
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--empty-state"
Targets
| Name | Description | Attribute |
|---|---|---|
list
required
|
The watched container whose children are counted. | data-stimeo--empty-state-target="list" |
empty
|
The placeholder shown when the list is empty. | data-stimeo--empty-state-target="empty" |
Values
| Name | Description | Attribute |
|---|---|---|
itemSelector
|
Selector that counts only matching child items; empty counts all element children. | data-stimeo--empty-state-item-selector-value |
announceText
|
Wording read out when the list becomes empty; empty announces nothing. {count} is expanded. |
data-stimeo--empty-state-announce-text-value |
announceFilledText
|
Wording read out when the list stops being empty; empty announces nothing. {count} is expanded. |
data-stimeo--empty-state-announce-filled-text-value |
Events
| Name | Description | Event |
|---|---|---|
change
|
Fires when the 0 ↔ 1+ boundary is crossed, with detail.count / detail.empty. |
stimeo--empty-state: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 |
|---|---|---|
hidden |
list / empty targets | Toggled to show the list (1+) or the empty placeholder (0). |
data-empty |
Controller element | Present when the item count is 0. |
data-count |
Controller element | The current item count. |