Intersection
stimeo--intersection
The groundwork that turns an element entering and leaving the screen into events and CSS values.
Watches whether an element is on screen and reports what changes. Anything triggered by scrolling is built on top of it: endless lists, numbers counting up, a reading bar, a header that gets out of the way. Becoming visible, leaving, and fully passing the top edge are each reported, and leaving says whether it went up or down. How much of it is visible is readable from CSS, so smoothly moving effects are easy to write, and so is whether it is visible at all, which is how everything in this demo is drawn with CSS alone. After you add content, the current state can be delivered again as a fresh change.
Scroll inside the frame. The box reacts to its own visibility with CSS alone: the border and label come from the visible state, and the bar from how much of it is showing.
Keyboard
This component has no keyboard interactions of its own.
<%# 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-700);
}
.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 the edge it left across — before (upward, past the start edge) or after (downward, still ahead). That is a separate reading from data-passed, which means a full crossing. |
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. |