Scroll Area
stimeo--scroll-area
Adds keyboard reachability and scroll-state hooks to a natively scrolling region.
The stimeo--scroll-area controller adds accessibility and CSS state to a natively scrolling region without drawing a custom scrollbar. When the content overflows and the viewport holds no focusable elements of its own, the viewport is made keyboard-scrollable (tabindex="0", plus role="region" when it already has an accessible name). Scroll position is published as data-scroll (start / middle / end), overflow as data-overflow, and progress as --stimeo-scroll-progress (0–1) so consumer CSS can draw scroll shadows. It dispatches stimeo--scroll-area:reach at the edges, and the scroll listener and resize observer are torn down on disconnect (Turbo included).
Keyboard
| Key | Action |
|---|---|
| ↑ / ↓ / ← / → | Scroll the region (browser default once focusable). |
| PageUp / PageDown | Scroll by one page. |
| Home / End | Scroll to the start / end. |
<%# Markup for the scroll-area demo.
The consumer (this markup) puts aria-label on the viewport. On overflow the library
adds tabindex to the viewport and (only when it has an accessible name) role="region",
and exposes data-scroll (start/middle/end) and --stimeo-scroll-progress.
Drawing the scroll shadow is demo.css's job, reading data-scroll. %>
<div class="scroll-area" data-controller="stimeo--scroll-area"
data-stimeo--scroll-area-orientation-value="vertical">
<div class="scroll-area__viewport"
data-stimeo--scroll-area-target="viewport"
aria-label="<%= t("components.scroll_area.demo.label") %>">
<p><%= t("components.scroll_area.demo.intro") %></p>
<% 12.times do |i| %>
<p class="scroll-area__line"><%= t("components.scroll_area.demo.line", number: i + 1) %></p>
<% end %>
</div>
</div>
/* Presentation CSS for scroll-area. The library handles overflow detection, keyboard
reachability, and exposing data-scroll, so visuals like the scroll shadow are drawn
here by reading data-scroll. */
.scroll-area {
position: relative;
max-width: 420px;
border: 1px solid var(--border-strong);
border-radius: 8px;
}
/* The viewport is the element that actually scrolls; the library adds tabindex/role on overflow. */
.scroll-area__viewport {
max-height: 180px;
overflow-y: auto;
padding: 1rem;
}
/* Visible keyboard focus (tabindex=0 is added on overflow). */
.scroll-area__viewport:focus-visible {
outline: 2px solid var(--color-primary-hover);
outline-offset: -2px;
}
.scroll-area__viewport p {
margin: 0 0 0.75rem;
color: var(--color-text);
}
.scroll-area__line {
padding: 0.25rem 0;
border-bottom: 1px dashed var(--border-default);
}
/* Using data-scroll as the hook, show the scroll shadow only at the top/bottom edges. */
.scroll-area::before,
.scroll-area::after {
content: "";
position: absolute;
left: 0;
right: 0;
height: 24px;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
}
.scroll-area::before {
top: 0;
background: linear-gradient(to bottom, rgba(15, 23, 42, 0.12), transparent);
}
.scroll-area::after {
bottom: 0;
background: linear-gradient(to top, rgba(15, 23, 42, 0.12), transparent);
}
/* If scrolled below the top (middle/end) show the top shadow; if above the bottom
show the bottom shadow. */
.scroll-area[data-scroll="middle"]::before,
.scroll-area[data-scroll="end"]::before {
opacity: 1;
}
.scroll-area[data-scroll="start"]::after,
.scroll-area[data-scroll="middle"]::after {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.scroll-area::before,
.scroll-area::after {
transition: none;
}
}
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--scroll-area"
Targets
| Name | Description | Attribute |
|---|---|---|
viewport
required
|
The natively scrolling region whose overflow and scroll position are tracked. | data-stimeo--scroll-area-target="viewport" |
Values
| Name | Description | Attribute |
|---|---|---|
orientation
|
The scroll axis to track, vertical, horizontal, or both (default vertical). |
data-stimeo--scroll-area-orientation-value |
Events
| Name | Description | Event |
|---|---|---|
reach
|
Dispatched once when scrolling reaches an edge; detail carries the edge (start/end). | stimeo--scroll-area:reach |
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-scroll |
Root element | "start" / "middle" / "end" scroll position. |
data-overflow |
Root element | "true" when the region is scrollable. |
--stimeo-scroll-progress |
Root element | 0–1 scroll progress. |