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 flags them 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 is declared as the hasNew
target: it carries hidden here, and the controller shows it only while messages have
arrived that the log did not follow. 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 follows/flags, reflects data-pinned / data-has-new and owns hidden
on the jump button; 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-stimeo--stick-to-bottom-target="hasNew"
data-action="click->stimeo--stick-to-bottom#scrollToBottom"
hidden>
<%= 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, reflects data-pinned / data-has-new and owns the jump button's hidden, so
* whether that button shows is not styled here; this CSS gives the log a fixed height so
* it scrolls and dresses the button as a floating pill.
*/
.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%;
}
/* The trailing text-align is what puts the jump button on the right; the message list
below opts back out of it. Alignment is kept off the button itself so its own display
stays unset and the `hidden` the controller writes always wins. */
.stb-demo__log {
position: relative;
width: 100%;
height: 9rem;
overflow: auto;
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 0.5rem;
text-align: right;
}
.stb-demo__messages {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.25rem;
text-align: left;
}
.stb-demo__messages li {
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
background: var(--surface-subtle);
}
/* The "new messages" button rides the bottom edge of the log while it is shown. */
.stb-demo__jump {
position: sticky;
bottom: 0;
vertical-align: bottom;
padding: 0.25rem 0.625rem;
border: 0;
border-radius: 999px;
background: var(--color-primary);
color: var(--white);
cursor: pointer;
}
// 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" |
hasNew
|
A region shown only while data-has-new is set; the controller owns its hidden, so the consumer needs no CSS for it. |
data-stimeo--stick-to-bottom-target="hasNew" |
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. |