Notebook & chapters
Engine Components · live from GitHub
Pin a heading, or select text and use the floating pin to save your place.
View source ↗

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

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

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

ts
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():

ts
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 / after state.

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:

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

ts
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

ts
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

ts
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

ts
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

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

ts
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

ts
timeline.seek(0.5);

timeline.seek(1, {
	duration: 350,
	easing: "easeOutCubic",
});

React

tsx
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

text
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 changes

No standalone ScrollTimeline schema type is required; timelines are orchestration inside EngineScroll.

EngineScroll Timeline | Kastrick