In-view state — "which section is on screen"

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.


The prop

"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:

KeyValue
<key>that member's value
<key>:index0-based position of value among the group's distinct values, in document order
<key>:numberindex + 1
<key>:counthow many distinct values the group has
  • Most on screen = visible px ÷ 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.
  • When nothing in the group is on screen, the last winner stays (the rail doesn't blank between groups).
  • Before the first measurement, the first value in document order is published, so counters never paint empty.
  • Writes happen only on change. Each write is kind: "value", lastWriter: "in-view".
  • Phases: distinct values, not members, are counted — repeat a value on several sections, or (simpler) put a second group on a wrapper Container per phase. Nested groups are independent.
  • Keys are colon-delimited and must not contain . (a . opens a JSON path inside {{state.*}}).

Reading it

WantUse
"3 / 8" counterText: {{state.deck:step:number}} / {{state.deck:step:count}}
Active rail itemstateModifiers: { type: "state", key: "deck:phase", operator: "equals", value: "build" }
Done (earlier) itemsstateModifiers: { type: "state", key: "deck:phase:index", operator: "greater-than", value: "<this item's index>" }
Upcoming itemssame with less-than
Show a panel only during a sectiona 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.

Runtimes

PathHow
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.

Example — deck rail + sections

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

Limits

  • Counter numbers are not zero-padded ("3", not "03"). Write 0{{state.deck:step:number}} for decks under ten steps.
  • Static export: stamped on Container only. A Data node's static output does not carry the attribute (React does).
  • Without IntersectionObserver (very old browsers) the first value is published and never changes.
  • The editor canvas does not run it; preview a published or /view URL to see the rail move.