Stick to Bottom
stimeo--stick-to-bottom
Follows new lines to the bottom, and stops following while you read back.
Keeps a growing area such as a chat or a log following its newest line, but only while you are already near the bottom. Scrolling up to read something is never interrupted by being dragged back down. Lines that arrive while you are reading back are reported as new, and a control such as a jump-to-latest button can take you to the bottom. Whether it is following is decided from where the scroll actually is rather than from an intention, so the two can never disagree.
Keyboard
This component has no keyboard interactions of its own.
<%# 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. pin-on-connect starts the log at the bottom so the
first message is followed rather than flagged — a fresh scroll container renders at the
top, and the controller does this on every connect, which a once-per-document page event
cannot. The library only follows/flags and reflects data-pinned / data-has-new; demo.css
owns the look.
The log scrolls but its only control — the jump button — is hidden until there is
something to jump to, so most of the time a keyboard user cannot reach it at all
(WCAG 2.1.1). Rather than hand-write a tab stop, compose stimeo--scroll-area over it:
that controller exists for exactly this and gives the viewport tabindex="0" only while
it holds no rendered focusable content, so the tab stop appears and disappears in step
with the jump button instead of doubling up with it. It goes on a wrapper because
stick-to-bottom must sit on the scrolling element itself, and it takes the log as its
viewport target. role="region" comes free with the label below. %>
<div class="stb-demo">
<div class="stb-demo__log-wrap" data-controller="stimeo--scroll-area">
<div
class="stb-demo__log"
data-controller="stimeo--stick-to-bottom"
data-stimeo--stick-to-bottom-behavior-value="smooth"
data-stimeo--stick-to-bottom-pin-on-connect-value="true"
data-stimeo--scroll-area-target="viewport"
aria-label="<%= t("components.stick_to_bottom.demo.viewport_label") %>">
<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>
</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;
}
/* Wrapper for the composed stimeo--scroll-area (it needs an element of its own, and
stick-to-bottom must stay on the scrolling log). Layout-neutral. */
.stb-demo__log-wrap {
width: 100%;
}
.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). The starting scroll position is not wired here: the
// log declares pin-on-connect, so it comes up at the bottom on its own.
document.querySelectorAll(".stb-demo").forEach((root) => {
const list = root.querySelector('[data-stimeo--stick-to-bottom-target="content"]');
const add = root.querySelector("[data-stb-demo-add]");
if (!list || !add) return;
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
|
Normal scroll behavior for the follow (auto / smooth; forced to instant under reduced motion). |
data-stimeo--stick-to-bottom-behavior-value |
pinOnConnect
|
Start at the bottom on connect, pinned if the jump got there (default false). The jump is always instant, whatever behavior says. |
data-stimeo--stick-to-bottom-pin-on-connect-value |
Actions
| Name | Description | Action |
|---|---|---|
scrollToBottom
|
Jumps to the bottom (a "new messages" button). Clears data-has-new on request; data-pinned follows where the scroll actually landed. | 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. |