Under the Hood
This guide explains what happens behind the scenes when you build and run a StageRoutine presentation: how the DOM is constructed, how reactive property mutations are captured, and how the animation loop renders frames.
Two-Phase Execution Model
StageRoutine runs in two separate phases:
- Recording Phase: When the page loads, your TypeScript code runs once from top to bottom. It creates DOM elements and records property changes into step queues.
- Playback Phase: When you call
stage.mount(), the stage displays elements in a virtual viewport and starts the interactive presentation engine.
Virtual Viewport
StageRoutine renders all content inside a fixed virtual viewport (by default 1920x1080 pixels).
Responsive Scaling
To look identical on any laptop, monitor, or projector, StageRoutine calculates a uniform scale factor:
const scale = Math.min(
window.innerWidth / 1920,
window.innerHeight / 1080
);
this.viewport.style.transform = `scale(${scale})`;
Stage Units
The viewport declares one CSS custom property, and every coordinate is a multiple of it:
--sr-u: calc(100cqh / 90);
The stage is 90 units tall, so one unit is 12px on a 1080p canvas. Both axes use the same
property, so a unit is the same distance horizontally and vertically.
When an element moves to (80, 40), StageRoutine converts it into a GPU-accelerated CSS transform:
node.style.transformOrigin = "0% 0%";
node.style.transform =
"translate3d(calc(80 * var(--sr-u)), calc(40 * var(--sr-u)), 0) translate(0%, 0%) scale(1) rotate(0deg)";
The second translate applies the element origin. With origin: "center" it becomes
translate(-50%, -50%) and transformOrigin becomes 50% 50%, so the element centers on its
coordinate and pivots there when scaled or rotated.
Reactive Proxies
To animate properties easily without boilerplate, elements are wrapped in a standard JavaScript Proxy.
A Proxy lets StageRoutine listen to property assignments:
title.x = to(80).duration(0.8).ease("quartOut");
When you write title.x = ..., the Proxy traps the assignment. Instead of modifying the DOM immediately, it stores a TransitionRecord:
host.recordMutation(
target.id, // "title-1"
"x", // property name
fromValue, // current value (e.g. 50)
80, // target value
800, // duration in ms
0, // delay in ms
curve // easing function
);
Steps vs. Snapshots
Whenever you call stage.pause(), StageRoutine commits the recorded transitions and takes a state snapshot:
// 1. Step Record (transitions to play forward)
this.steps.push({
sceneName: this.currentSceneName,
stepIndex: stepIdx,
transitions: [...this.currentStepTransitions],
activeElementIds: new Set(this.activeElementIds),
});
// 2. State Snapshot (exact property values for instant seeking)
this.snapshots.push({
sceneName: this.currentSceneName,
stepIndex: stepIdx,
properties: clone(this.propertyState),
activeElementIds: new Set(this.activeElementIds),
});
Why Both?
- Moving Forward (
nextStep): The stage animates transitions smoothly over time using the frame loop. - Moving Backward or Scrubbing (
prevStep,gotoStep): The stage instantly restores exact property values from the snapshot. This keeps navigation fast and free of accumulated floating-point errors.
Animation Loop
Animations are driven by requestAnimationFrame.
requestAnimationFrame is the browser's native clock for screen repainting. It calls the render function right before every monitor refresh (typically 60 or 120 times per second).
Milestone Dependency Resolution
If you synchronize elements with .when(), StageRoutine calculates start offsets before the loop begins:
// .when(title, "halfway") resolves to a concrete start time:
if (milestone === "halfway") {
baseStart = parent.startOffsetMs + parent.durationMs * 0.5;
} else if (milestone === "end") {
baseStart = parent.endOffsetMs;
}
transition.startOffsetMs = baseStart + delayMs;
transition.endOffsetMs = transition.startOffsetMs + transition.durationMs;
Frame Rendering
On every frame tick:
- Calculate Progress: Computes
progress = (elapsed - startOffsetMs) / durationMs(clamped between0.0and1.0). - Apply Easing: Calculates the curved progress value using easing functions like
quartOut. - Interpolate Values: Blends from the start value to the target value based on progress:
- Numbers:
start + (target - start) * progress(e.g.0to100at 50% progress =50) - Colors: Smoothly blends RGB and Alpha channels.
- CSS units: Interpolates the numerical value and keeps the unit (e.g.
20px→80px).
- Numbers:
- Write to DOM: Directly updates the element's inline GPU styles (
transform,opacity, andfilter).
// Frame render writes directly to DOM styles:
node.style.transform = `translate3d(calc(${currentX} * var(--sr-u)), calc(${currentY} * var(--sr-u)), 0) scale(${currentScale})`;
node.style.opacity = `${currentOpacity}`;
Once all transitions complete, the loop stops, and the stage snaps to the final clean snapshot state.