ビューポート交差
stimeo--intersection
要素が画面に入ったこと出たことを、イベントと CSS の値にして渡す土台です。
要素が画面に入ったかどうかを見て、その変化を知らせます。無限スクロール、数字のカウントアップ、読了バー、隠れるヘッダなど、スクロールをきっかけに動く仕掛けは、これを土台に組み立てます。見えるようになったとき、去ったとき、上端を完全に通り過ぎたときが、それぞれイベントになります。去ったときは、上へ抜けたのか下へ抜けたのかも分かります。見えている割合は CSS から読めるので、滑らかに動く表現も書けます。今見えているかどうかも CSS から読めるので、このデモの表示はすべて CSS だけで作られています。中身を足したあとに、いまの状態をもう一度届け直すこともできます。
枠の中をスクロールしてください。ボックスは自分が見えているかどうかに CSS だけで反応しています。枠線とラベルは表示状態から、バーは見えている割合から作られています。
キーボード操作
このコンポーネント自体はキーボード操作を持ちません。
<%# 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;
}
このデモに固有の消費側 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(末端側=まだ先)。完全通過を表す data-passed とは別の判定です。 |
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 を上げると滑らかに更新される。 |