Intersection
stimeo--intersection
A declarative IntersectionObserver: visibility as events and CSS hooks.
The stimeo--intersection controller is a thin declarative wrapper over IntersectionObserver — the scroll-triggered building block infinite scroll, count-up animations, reading progress, and smart sticky headers compose from. It observes its own element and turns visibility into transitions: enter when the element becomes visible (the intersection ratio reaches threshold), exit when it leaves (with the direction it went), passed when it fully crosses the root's start edge in either direction, and change on every observed update (set ratioSteps for a smooth ratio stream). The state is mirrored for CSS: data-intersecting, data-passed, and the --stimeo--intersection-ratio custom property — this demo is styled entirely from those hooks, with no consumer JS. The refresh action re-delivers the current state as a fresh transition, which un-stalls the classic hand-rolled infinite scroll whose sentinel never leaves the viewport. With once, the observer stops after the first enter (lazy loads, one-shot animations). connect() is idempotent: a Turbo cache restore does not re-fire enter for an element already recorded as visible.
Scroll inside the frame. The box reacts to its own visibility purely via CSS hooks — border and label from data-intersecting / data-passed, the fill bar from the ratio custom property.
<%# intersection: the primitive turns viewport visibility into events and hooks.
No consumer JS here — everything visible is pure CSS reacting to the hooks the
controller flips: data-intersecting (in/out), data-passed (scrolled past the
top), and the --stimeo--intersection-ratio custom property (the fill bar).
ratioSteps makes the ratio update smoothly while scrolling. %>
<div class="intersection-demo">
<p class="intersection-demo__hint"><%= t("components.intersection.demo.hint") %></p>
<div class="intersection-demo__viewport" id="intersection-viewport" role="region" tabindex="0"
aria-label="<%= t('components.intersection.demo.viewport_label') %>">
<p class="intersection-demo__filler"><%= t("components.intersection.demo.before") %></p>
<div class="intersection-demo__box"
data-controller="stimeo--intersection"
data-stimeo--intersection-root-selector-value="#intersection-viewport"
data-stimeo--intersection-ratio-steps-value="20">
<span class="intersection-demo__state intersection-demo__state--in">
<%= t("components.intersection.demo.state_visible") %>
</span>
<span class="intersection-demo__state intersection-demo__state--out">
<%= t("components.intersection.demo.state_hidden") %>
</span>
<span class="intersection-demo__state intersection-demo__state--passed">
<%= t("components.intersection.demo.state_passed") %>
</span>
<span class="intersection-demo__ratio" aria-hidden="true"></span>
</div>
<p class="intersection-demo__filler"><%= t("components.intersection.demo.after") %></p>
</div>
</div>
/*
* Presentation-only styles for the intersection demo. The library flips
* data-intersecting / data-passed and updates --stimeo--intersection-ratio; this
* CSS reacts to those hooks — no consumer JS at all. The state labels are all in
* the markup (localized); each shows only in its own state.
*/
.intersection-demo {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.intersection-demo__hint {
margin: 0;
color: var(--muted);
}
.intersection-demo__viewport {
height: 14rem;
overflow-y: auto;
padding: 1rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
}
.intersection-demo__viewport:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Tall filler so the observed box scrolls fully in and out of the root. */
.intersection-demo__filler {
margin: 0;
padding: 7rem 0;
color: var(--muted);
text-align: center;
}
.intersection-demo__box {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
border: 2px solid var(--border);
border-radius: 0.5rem;
background: var(--bg);
transition: border-color 0.15s ease;
}
.intersection-demo__box[data-intersecting="true"] {
border-color: var(--accent);
}
/* Exactly one state label shows: visible / not visible / scrolled past. */
.intersection-demo__state {
display: none;
font-weight: 600;
}
.intersection-demo__box[data-intersecting="true"] .intersection-demo__state--in {
display: block;
color: var(--accent);
}
.intersection-demo__box:not([data-intersecting="true"]) .intersection-demo__state--out {
display: block;
color: var(--muted);
}
.intersection-demo__box[data-passed="true"] .intersection-demo__state--out {
display: none;
}
.intersection-demo__box[data-passed="true"] .intersection-demo__state--passed {
display: block;
color: var(--muted);
}
/* Fill bar driven by the ratio custom property the controller maintains. */
.intersection-demo__ratio {
display: block;
height: 0.5rem;
border-radius: 0.25rem;
background: var(--border);
overflow: hidden;
position: relative;
}
.intersection-demo__ratio::before {
content: "";
position: absolute;
inset: 0;
width: calc(var(--stimeo--intersection-ratio, 0) * 100%);
background: var(--accent);
transition: width 0.1s linear;
}
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--intersection"
Values
| Name | Description | Attribute |
|---|---|---|
threshold
|
Visibility line as an intersection ratio (0..1). 0 (default) means any overlap counts. | data-stimeo--intersection-threshold-value |
ratioSteps
|
When > 0, observe N evenly spaced thresholds for a fine-grained change / ratio stream. Default 0. |
data-stimeo--intersection-ratio-steps-value |
rootMargin
|
Observer rootMargin (e.g. 200px to count as visible 200px early). Default 0px. |
data-stimeo--intersection-root-margin-value |
rootSelector
|
Selector for the scroll container to observe against; empty (default) means the viewport. | data-stimeo--intersection-root-selector-value |
once
|
Stop observing after the first enter, leaving the hooks in their final state. Default false. |
data-stimeo--intersection-once-value |
Actions
| Name | Description | Action |
|---|---|---|
refresh
|
Re-deliver the current state as a fresh transition (a still-visible sentinel re-fires enter) — call it after appending content. |
stimeo--intersection#refresh |
Events
| Name | Description | Event |
|---|---|---|
enter
|
Fires with { ratio } when the element becomes visible. |
stimeo--intersection:enter |
exit
|
Fires with { ratio, position } when it leaves; position is before (scrolled past) or after (still ahead). |
stimeo--intersection:exit |
change
|
Fires with { intersecting, ratio } on every observed update. |
stimeo--intersection:change |
passed
|
Fires with { passed } when the element fully crosses the root's start edge, in either direction. |
stimeo--intersection:passed |
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-intersecting |
Controller element | "true" while the element counts as visible (ratio at or past threshold), else "false". |
data-passed |
Controller element | "true" once the element sits entirely before the root's start (top) edge — the sticky/progress line. |
--stimeo--intersection-ratio |
Controller element (CSS custom property) | The latest intersection ratio (0..1); raise ratioSteps for smooth updates. |