EngineScroll Timeline
EngineScroll timelines are part of the core EngineScroll runtime. They do not create a second scroll listener or requestAnimationFrame loop. Timeline subscribers, crossings, activity events, tracks, and style bindings are driven by the same coalesced runtime updates that already power point navigation, viewport state, physics, snapping, and programmatic movement.
Create a timeline
import { EngineScroll } from "@/engine";
const timeline = EngineScroll.timeline({
start: "#hero",
end: "#features",
source: "current",
});
const stop = timeline.subscribe((frame) => {
console.log(frame.progress);
console.log(frame.rawProgress);
console.log(frame.direction);
console.log(frame.velocity);
});Since v2.6.2, #hero / #features use the same target semantics as
EngineScroll.move("#hero"): a registered EngineScroll point is preferred, then
EngineScroll falls back to a normal DOM element with the matching id.
Plain DOM ids are resolved live rather than being permanently cached by the registered-point revision. That means a target can mount after Timeline creation or move after a dynamic layout update and still resolve correctly. Registered points keep the faster cached geometry path.
Timeline source and viewport focus
source chooses the viewport point that drives timeline progress:
source: "top"
source: "current"
source: "bottom"top and bottom are exact physical viewport edges. current is the logical
viewport focus.
Starting in v2.6.2, the default focus strategy is "progressive": at document
start it resolves to the viewport top, halfway through the document it resolves
to the viewport center, and at the document end it resolves to the viewport
bottom. This avoids giving reading/progress timelines a permanent half-viewport
head start.
Applications that intentionally want the historical permanent center behavior can opt back in:
EngineScroll.setFocus("center");Other supported values are "top", "bottom", "progressive", or a custom
numeric focus from 0..1. See enginescroll-focus.md.
Full frames vs visual progress
subscribe() is the full-fidelity channel. It can emit while a timeline is
before or after its range because point, rawProgress, direction, or velocity
may still be changing.
For UI or effects that only care about the clamped timeline state, use
subscribeProgress():
const stopProgress = timeline.subscribeProgress((frame) => {
console.log(frame.progress);
});The progress channel emits only when one of these changes:
- resolved start/end boundaries;
- eased
progress; before/active/afterstate.
So a timeline already pinned at progress 0 or 1 does not keep waking
progress-only consumers while the page scrolls farther away from its range.
Both channels still share the timeline's one EngineScrollRuntime
subscription. The easing function selected by the timeline is also resolved once
at construction instead of being looked up again on every runtime frame.
Crossing events
Use onCross() for one-shot orchestration at a normalized position. Crossings
use raw timeline progress, so they fire in both directions even when the
timeline has easing:
const stopHalfway = timeline.onCross(0.5, (event) => {
if (event.direction > 0) {
console.log("crossed halfway going forward");
} else {
console.log("crossed halfway going backward");
}
});onEnter() and onLeave() expose active-range transitions:
const stopEnter = timeline.onEnter((event) => {
console.log(event.boundary); // "start" or "end"
console.log(event.direction);
});
const stopLeave = timeline.onLeave((event) => {
console.log(event.boundary);
});The timeline owns only one subscription to EngineScrollRuntime, regardless of
how many normal subscribers, progress subscribers, crossing callbacks, and
activity callbacks it has. When the last listener is removed, that runtime
subscription is released.
Calling snapshot() manually does not consume or suppress later crossing
events. Event detection keeps a separate runtime-observed frame baseline.
Frame state
interface EngineScrollTimelineFrame {
point: number;
startPoint: number | null;
endPoint: number | null;
rawProgress: number;
progress: number;
before: boolean;
active: boolean;
after: boolean;
direction: -1 | 0 | 1;
velocity: number;
}progress is clamped to 0..1. rawProgress remains unbounded so
orchestration code can tell how far before or after the range the viewport is.
direction and velocity represent physical page movement and are measured
from the viewport's top scroll edge. They therefore remain stable even when the
logical current focus strategy changes.
Alignment and offsets
const timeline = EngineScroll.timeline({
start: "#hero",
end: "#features",
startAlign: "center",
endAlign: "end",
startOffset: -4,
endOffset: 8,
});Alignment/offset behavior is shared with EngineScroll.move() and Range.
Segments and tracks
const fadeProgress = timeline.segment(0, 0.25);
const parallaxProgress = timeline.segment(0.15, 0.8, "easeOutCubic");
const parallaxY = timeline.track([
{ at: 0, value: 80, easing: "easeOutCubic" },
{ at: 0.5, value: 0 },
{ at: 1, value: -40 },
]);Tracks do not own listeners or RAF loops; value() reads parent timeline
progress.
Direct DOM/CSS bindings
const stopBinding = timeline.bindStyles(element, {
opacity: [0, 1],
"--hero-y": {
from: 80,
to: 0,
unit: "px",
easing: "easeOutCubic",
},
});Declarative tuple/range/keyframe bindings depend only on normalized progress, so
the binding runtime automatically uses subscribeProgress() and skips the whole
binding pass when progress is unchanged.
Function bindings remain full-fidelity because they may inspect rawProgress,
direction, velocity, or any other frame field:
timeline.bindStyles(element, {
"--state": (frame) => `${frame.rawProgress}:${frame.velocity}`,
});Bindings also cache their previous serialized values and skip identical DOM writes, avoiding a React render and unnecessary style mutations for high-frequency scroll visuals.
Scrubbing / seeking
timeline.seek(0.5);
timeline.seek(1, {
duration: 350,
easing: "easeOutCubic",
});React
import { useEngineScrollTimeline } from "@/engine";
function HeroProgress() {
const timeline = useEngineScrollTimeline({
start: "#hero",
end: "#features",
source: "current",
});
return <progress max={1} value={timeline.progress} />;
}The hook uses useSyncExternalStore and does not attach browser listeners
itself. For animation-heavy DOM work, prefer bindStyles().
Performance model
native scroll/resize/input
↓
BrowserScheduler (single RAF)
↓
EngineScroll runtime update
↓
viewport / physics / animation
↓
runtime.notify()
↓
full timeline events where requested
↓
progress-only consumers only when visual state changesNo standalone ScrollTimeline schema type is required; timelines are orchestration inside EngineScroll.