ビューポート交差
stimeo--intersection
IntersectionObserver を宣言的に扱い、可視状態をイベントと CSS フックへ変換する基盤プリミティブ。
stimeo--intersection は IntersectionObserver の薄い宣言的ラッパで、無限スクロール・カウントアップ・読了プログレス・スマート追従ヘッダなど「スクロール起動」の UI が合成の土台にするプリミティブです。自要素を監視し、可視状態を遷移として届けます。可視になれば enter(交差率が threshold に到達)、去れば exit(去った方向つき)、 root の始端を完全に通過すれば passed(両方向)、そして観測のたびに change (ratioSteps を上げると交差率が滑らかに流れます)。状態は CSS 向けにも反映されます — data-intersecting・data-passed・カスタムプロパティ --stimeo--intersection-ratio。このデモの見た目はすべてそのフックだけで作られており、消費側 JS はありません。 refresh アクションは現在の状態を新しい遷移として再配信し、「番兵が見えたまま」で止まる自作無限スクロールのストールを解消します。once を使うと初回 enter で監視を停止します(遅延ロード・一度きりのアニメ向け)。connect() は冪等で、Turbo のキャッシュ復元では記録済みの可視状態に enter を再発火しません。
枠の中をスクロールしてください。ボックスは自分の可視状態に CSS フックだけで反応します — 枠線とラベルは data-intersecting / data-passed、バーは交差率カスタムプロパティ由来です。
<%# 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;
}
このデモに固有の消費側 JS はありません(挙動はコントローラが担います)。
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。 共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--intersection"
値(Values)
| 名前 | 説明 | 属性 |
|---|---|---|
threshold
|
可視とみなす交差率の線(0..1)。既定 0(1px でも重なれば可視)。 | data-stimeo--intersection-threshold-value |
ratioSteps
|
0 より大きいと 0..1 を N 等分した threshold 列で観測し、change / ratio が細かく流れる。既定 0。 |
data-stimeo--intersection-ratio-steps-value |
rootMargin
|
Observer の rootMargin(例 200px で 200px 手前から可視扱い)。既定 0px。 |
data-stimeo--intersection-root-margin-value |
rootSelector
|
監視基準にするスクロールコンテナのセレクタ。空(既定)ならビューポート。 | data-stimeo--intersection-root-selector-value |
once
|
初回 enter で監視を停止し、フックを最終状態のまま残す。既定 false。 |
data-stimeo--intersection-once-value |
アクション
| 名前 | 説明 | アクション |
|---|---|---|
refresh
|
現在の交差状態を新しい遷移として再配信する(見えたままの番兵が enter を再発火)。コンテンツ append 後に呼ぶ。 |
stimeo--intersection#refresh |
イベント
| 名前 | 説明 | イベント |
|---|---|---|
enter
|
可視への遷移で { ratio } と共に発火。 |
stimeo--intersection:enter |
exit
|
不可視への遷移で { ratio, position } と共に発火。position は before(通過)/ after(まだ先)。 |
stimeo--intersection:exit |
change
|
観測のたびに { intersecting, ratio } と共に発火。 |
stimeo--intersection:change |
passed
|
root 始端の完全通過状態が変わると { passed } と共に発火(両方向)。 |
stimeo--intersection:passed |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
data-intersecting |
コントローラ要素 | 可視(交差率が threshold 以上)の間 "true"、それ以外は "false"。 |
data-passed |
コントローラ要素 | root の始端(上端)を完全に通過すると "true"。sticky・進捗系の「線越え」フック。 |
--stimeo--intersection-ratio |
コントローラ要素(CSS カスタムプロパティ) | 最新の交差率(0..1)。ratioSteps を上げると滑らかに更新される。 |