Stick-to-Bottom
stimeo--stick-to-bottom
Auto-follows appended content to the bottom of a scroll container — unless the user has scrolled up.
The stimeo--stick-to-bottom controller keeps a scroll container (a chat log, a live console) pinned to its newest content — but only while the user is already near the bottom. The container is pinned while its distance from the bottom is within threshold. A MutationObserver on content (or the element) reacts to appended children: while pinned it scrolls to the bottom, while unpinned it holds position, sets data-has-new, and emits new. Scrolling recomputes pinned and reflects data-pinned, emitting pin on change; the scrollToBottom action jumps back down (a "new messages" button). Behavior only — it does not add content (Turbo Stream / the consumer does) and is the minimal follow primitive, not a chat UI (no virtualization or input — that is the premium Chat Base). It is the lightweight member of the scroll family. State is derived from the scroll position each pass (no module-scope state), so connect re-syncs after a Turbo Stream insert; behavior falls back to auto under reduced motion; the auto-scroll never moves focus; and the observer and passive scroll listener are released on disconnect (Turbo navigation included).
<%# Stick-to-bottom demo: the scrollable log follows new messages while you're at the
bottom, but holds and shows the "new messages" jump button if you've scrolled up. This
catalog has no Action Cable / Turbo Stream, so the Add button appends a <li> — exactly
the mutation a Turbo Stream broadcast would make — and the controller reacts. The jump
button lives inside the log (a descendant, wired with a plain data-action) and CSS
reveals it only while data-has-new. demo.js starts the log scrolled to the bottom. The
library only follows/flags and reflects data-pinned / data-has-new; demo.css owns the look. %>
<div class="stb-demo">
<div
class="stb-demo__log"
data-controller="stimeo--stick-to-bottom"
data-stimeo--stick-to-bottom-behavior-value="smooth">
<ul class="stb-demo__messages" data-stimeo--stick-to-bottom-target="content">
<% (1..8).each do |i| %>
<li><%= t("components.stick_to_bottom.demo.message") %> <%= i %></li>
<% end %>
</ul>
<button
type="button"
class="stb-demo__jump"
data-action="click->stimeo--stick-to-bottom#scrollToBottom">
<%= t("components.stick_to_bottom.demo.jump") %>
</button>
</div>
<button
type="button"
class="demo-trigger"
data-stb-demo-add
data-message-label="<%= t("components.stick_to_bottom.demo.message") %>">
<%= t("components.stick_to_bottom.demo.add") %>
</button>
</div>
/*
* Presentation-only styles for the stick-to-bottom demo. The library follows/flags new
* content and reflects data-pinned / data-has-new; this CSS gives the log a fixed height
* so it scrolls, and reveals the floating jump button only while data-has-new is set.
*/
.stb-demo {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 24rem;
align-items: flex-start;
}
.stb-demo__log {
position: relative;
width: 100%;
height: 9rem;
overflow: auto;
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 0.5rem;
}
.stb-demo__messages {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.stb-demo__messages li {
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
background: var(--surface-subtle);
}
/* The "new messages" button floats at the bottom, shown only while scrolled up. */
.stb-demo__jump {
position: sticky;
bottom: 0;
display: none;
margin-left: auto;
padding: 0.25rem 0.625rem;
border: 0;
border-radius: 999px;
background: var(--color-primary);
color: var(--white);
cursor: pointer;
}
.stb-demo__log[data-has-new] .stb-demo__jump {
display: block;
}
// Stick-to-bottom demo (consumer-side JS).
//
// No Action Cable / Turbo Stream here, so the Add button appends a <li> to the log —
// the same mutation a broadcast would make — and the controller follows it (while pinned)
// or flags it (while scrolled up). We also start the log scrolled to the bottom so the
// follow behavior is visible from the first message.
document.querySelectorAll(".stb-demo").forEach((root) => {
const log = root.querySelector('[data-controller~="stimeo--stick-to-bottom"]');
const list = root.querySelector('[data-stimeo--stick-to-bottom-target="content"]');
const add = root.querySelector("[data-stb-demo-add]");
if (!log || !list || !add) return;
// Start at the bottom (pinned); the scroll fires the controller's pinned recompute.
log.scrollTop = log.scrollHeight;
let count = list.children.length;
const label = add.dataset.messageLabel ?? "Message";
add.addEventListener("click", () => {
const li = document.createElement("li");
li.textContent = `${label} ${(count += 1)}`;
list.appendChild(li);
});
});
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--stick-to-bottom"
Targets
| Name | Description | Attribute |
|---|---|---|
content
|
The append-watched element (defaults to the scroll container itself). | data-stimeo--stick-to-bottom-target="content" |
Values
| Name | Description | Attribute |
|---|---|---|
threshold
|
Distance from the bottom (px) to count as pinned (default 80). | data-stimeo--stick-to-bottom-threshold-value |
behavior
|
Scroll behavior for the follow (auto / smooth; forced to auto under reduced motion). |
data-stimeo--stick-to-bottom-behavior-value |
Actions
| Name | Action |
|---|---|
scrollToBottom
|
stimeo--stick-to-bottom#scrollToBottom |
Events
| Name | Description | Event |
|---|---|---|
pin
|
Fires when the pinned state changes, with detail.pinned. |
stimeo--stick-to-bottom:pin |
new
|
Fires when content arrives while unpinned, with detail.count. |
stimeo--stick-to-bottom:new |
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-pinned |
Controller element | Present (true) while following the bottom. |
data-has-new |
Controller element | Present (true) when new content arrived while scrolled up. |