A Container prop that writes the reader's position into the [state registry](state-system.md), so a sticky rail, a "03 / 08" counter or a step highlight can follow the scroll with nothing but the existing readers: {{state.}} in Text, sta…
A Container prop that writes the reader's position into the state registry, so a sticky rail, a "03 / 08" counter or a step highlight can follow the scroll with nothing but the existing readers: {{state.*}} in Text, stateModifiers, state conditions, visibilityStateKey. No inject scripts, no handlers.
Source: utils/state/inViewTracker.ts. Tests: pnpm test:in-view-state.
"inViewState": { "key": "deck:step", "value": "pricing" }
Every Container sharing key is one group. While a member is the one most on screen, the registry holds:
| Key | Value |
|---|---|
<key> | that member's value |
<key>:index | 0-based position of value among the group's distinct values, in document order |
<key>:number | index + 1 |
<key>:count | how many distinct values the group has |
min(height, viewport height). A short section shown in full and a tall one filling the screen both score 1, so a short last section can still win at the bottom of the page. Ties (within 1%) go to the topmost.kind: "value", lastWriter: "in-view".. (a . opens a JSON path inside {{state.*}}).| Want | Use |
|---|---|
| "3 / 8" counter | Text: {{state.deck:step:number}} / {{state.deck:step:count}} |
| Active rail item | stateModifiers: { type: "state", key: "deck:phase", operator: "equals", value: "build" } |
| Done (earlier) items | stateModifiers: { type: "state", key: "deck:phase:index", operator: "greater-than", value: "<this item's index>" } |
| Upcoming items | same with less-than |
| Show a panel only during a section | a Container with a state condition, or visibilityStateKey if you write shown/hidden values |
greater-than / less-than compare numerically and work on both runtimes; the editor's State condition picker offers them for state conditions.
Per-phase progress bars: draw one segment per step inside each phase and give each segment a done modifier (deck:step:index greater-than <i-1>, i.e. reached). A continuous width tween isn't expressible with classes — stateStyleBindings can write deck:step:number into a CSS variable if you need one.
| Path | How |
|---|---|
React viewer (/view fallback, /[[...slug]], /static without staticPublish) | useContainerInViewState in both Container bodies joins the element to a shared tracker that writes through setState. Off on the editor canvas (!ctx.enabled, same gate as scroll effects). Also active on Data (shares the body). |
Static export (/view default, /api/published/*) | Container.toHTML stamps data-in-view-state='{"key","value"}'. renderToHTML appends a runtime extension (getInViewStateScript, pushed on window.__PH_RT_EXT__ like the site-chat chunk) only when a node uses the prop; it writes through __phRT.setState, so the existing data-state-template / data-state-modifiers directives react. |
Both paths run the same two functions — pickInViewWinner and createInViewTracker are self-contained so the static script can inline them by Function.prototype.toString. Keep them free of imports and module-scope references.
Mechanics: one IntersectionObserver per group (thresholds every 5%). On any crossing the tracker re-measures the group's intersecting members with getBoundingClientRect (exact, not the stale entry) and picks the winner.
Site-saved modifiers (ROOT.props.modifiers) — the rail's two looks:
"modifiers": {
"Container": [
{ "name": "rail-active", "label": "Rail active", "category": "State", "classes": "text-primary border-primary" },
{ "name": "rail-done", "label": "Rail done", "category": "State", "classes": "border-primary/40 text-base-content/60" }
]
}
Rail (sticky, inside a two-column page layout):
"rail": {
"type": { "resolvedName": "Container" }, "isCanvas": true, "parent": "page_home",
"nodes": ["rail_counter", "rail_collect", "rail_build", "rail_ship"],
"props": { "type": "aside", "className": "hidden lg:flex flex-col gap-space-md sticky top-0 h-screen w-64 shrink-0 p-space-lg bg-base-200 text-base-content" }
},
"rail_counter": {
"type": { "resolvedName": "Text" }, "parent": "rail", "nodes": [],
"props": { "tagName": "p", "className": "font-heading text-2xl tabular-nums", "text": "{{state.deck:step:number}} / {{state.deck:step:count}}" }
},
"rail_collect": {
"type": { "resolvedName": "Container" }, "isCanvas": true, "parent": "rail", "nodes": ["rail_collect_t"],
"props": {
"className": "border-l-2 border-base-content/15 pl-space-sm text-base-content/50",
"action": [{ "type": "link", "href": "#phase-collect" }],
"stateModifiers": [
{ "conditions": [{ "logic": "all", "conditions": [{ "type": "state", "key": "deck:phase", "operator": "equals", "value": "collect" }] }], "modifiers": ["rail-active"] },
{ "conditions": [{ "logic": "all", "conditions": [{ "type": "state", "key": "deck:phase:index", "operator": "greater-than", "value": "0" }] }], "modifiers": ["rail-done"] }
]
}
},
"rail_collect_t": { "type": { "resolvedName": "Text" }, "parent": "rail_collect", "nodes": [], "props": { "tagName": "span", "className": "text-xs font-semibold uppercase tracking-widest", "text": "Collect" } }
// rail_build: same, value "build", done when deck:phase:index greater-than "1"
// rail_ship: same, value "ship", done when deck:phase:index greater-than "2"
Sections — one wrapper per phase (phase group), one Container per slide (step group):
"phase_collect": {
"type": { "resolvedName": "Container" }, "isCanvas": true, "parent": "deck_main",
"nodes": ["step_gather", "step_sort"],
"props": { "type": "section", "id": "phase-collect", "className": "w-full flex flex-col",
"inViewState": { "key": "deck:phase", "value": "collect" } }
},
"step_gather": {
"type": { "resolvedName": "Container" }, "isCanvas": true, "parent": "phase_collect", "nodes": ["…"],
"props": { "className": "w-full min-h-screen flex flex-col justify-center px-container-x py-space-lg",
"inViewState": { "key": "deck:step", "value": "gather" } }
}
// step_sort under phase_collect; phase_build → step_draft; phase_ship → step_send
0{{state.deck:step:number}} for decks under ten steps.Container only. A Data node's static output does not carry the attribute (React does).IntersectionObserver (very old browsers) the first value is published and never changes./view URL to see the rail move.