Skill 18 · Hyperframes Animation
Subchapter 18.80
rules/dynamic-content-sequencing.mdMarkdown8 KBView on GitHub
A utility pattern (not a motion rule in itself) for scenes that show a SEQUENCE of items (cards, phrases, stats): each item’s duration is computed from its content length + per-item config, and the sequencer assigns absolute start/end times automatically — no hardcoded offsets per item. Distinct from discrete-text-sequence (one text element changing states) — this rule swaps between distinct content blocks.
A content array of { eyebrow, title, body, speedFactor, hold } entries is reduced once at build time into a flat TIMELINE of { …entry, start, end } — duration per entry is BASE_DURATION + body.length × SEC_PER_CHAR + hold, so longer text earns more reading time. A single linear driver’s onUpdate reverse-searches the active entry and swaps the DOM only on transitions (a lastTitle guard — per-frame textContent writes flicker in render); an optional progress bar fills 0→100% across the whole run.
<!-- inside a standard scene clip (hyperframes-core) -->
<div class="display">
<div class="eyebrow" id="eyebrow"></div>
<div class="title" id="title"></div>
<div class="body" id="body"></div>
<div class="progress-bar"><div class="progress-fill" id="progress-fill"></div></div>
</div>.body {
min-height: 160px; /* reserve space — content height varies; without this, layout jumps */
}
.progress-fill {
height: 100%;
width: 0%;
}// N entries, each with its own pacing (optionally a speedFactor multiplier);
// the final entry uses a larger hold (closing beat).
const CONTENT = [
{ eyebrow: "{eyebrow1}", title: "{title1}", body: "{body1}", hold: HOLD_MID },
// …
{ eyebrow: "{eyebrowN}", title: "{titleN}", body: "{bodyN}", hold: HOLD_FINAL },
];
// Pre-compute absolute start/end ONCE — never in onUpdate.
let cumulative = 0;
const TIMELINE = CONTENT.map((entry) => {
const dur = BASE_DURATION + entry.body.length * SEC_PER_CHAR + entry.hold;
const start = cumulative;
cumulative += dur;
return { ...entry, start, end: cumulative };
});
function entryAt(time) {
for (let i = TIMELINE.length - 1; i >= 0; i--) {
if (time >= TIMELINE[i].start) return TIMELINE[i];
}
return TIMELINE[0];
}
const eyebrowEl = document.getElementById("eyebrow");
const titleEl = document.getElementById("title");
const bodyEl = document.getElementById("body");
const progressEl = document.getElementById("progress-fill");
const TOTAL_DURATION = cumulative + TAIL_PAD;
const driver = { t: 0 };
let lastTitle = "";
tl.to(
driver,
{
t: TOTAL_DURATION,
duration: TOTAL_DURATION,
ease: "none",
onUpdate: () => {
const entry = entryAt(driver.t);
// Swap content only on transitions — no per-frame DOM thrash
if (entry.title !== lastTitle) {
eyebrowEl.textContent = entry.eyebrow;
titleEl.textContent = entry.title;
bodyEl.textContent = entry.body;
lastTitle = entry.title;
}
progressEl.style.width = `${(driver.t / TOTAL_DURATION) * 100}%`;
},
},
0,
);time ≥ e.start − overlap && time ≤ e.end + overlap, overlap ≈ 0.3s) and render them with opacities computed from distance to the boundary.entry.style key to an existing rule per chapter (e.g. 3d-text-depth-layers → hacker-flip-3d → counting-dynamic-scale); the sequencer only orchestrates timing.data-duration from the computed TOTAL_DURATION in script, but HF reads data-duration at composition load and setting it after init may not take effect — author the duration manually from a rough total.For rhetorical escalation — “everyone says…”, a roll-call, a praise flurry — the beat grid itself accelerates: early entries hold ~1s (read speed), then windows shrink geometrically into a ~0.15–0.3s flurry, braking on an emphasis state before the resolve. The acceleration is pre-computed into the same flat TIMELINE — still content-driven, still deterministic, no speed-up tween anywhere:
// Geometric decay on the hold, clamped at a flurry floor; the brake state holds longest.
const HOLDS = CONTENT.map((entry, i) => Math.max(FLURRY_FLOOR, HOLD_START * Math.pow(DECAY, i)));
HOLDS[CONTENT.length - 1] = HOLD_FINAL;
let cumulative = 0;
const TIMELINE = CONTENT.map((entry, i) => {
// Past ~0.5s states are glanced as motion texture, not read —
// drop the per-char term or you never reach flurry speed.
const readable = HOLDS[i] >= READ_THRESHOLD;
const dur = HOLDS[i] + (readable ? entry.body.length * SEC_PER_CHAR : 0);
const start = cumulative;
cumulative += dur;
return { ...entry, start, end: cumulative };
});Worked example — praise-chip flurry: ~16 short quotes hard-cut through a chip beside a pinned wordmark. First 3 states at HOLD_START = 1.0 (each reads fully); DECAY = 0.8 shrinks every following window until FLURRY_FLOOR = 0.2 catches it (≈12 states over ~2.5s — a churn of acclaim, individually glanced); the longest phrase takes HOLD_FINAL ≈ 1.6 as the brake before the closing lockup.
Values: HOLD_START 0.8–1.2s; DECAY 0.75–0.88 (higher = longer runway before the flurry bites); FLURRY_FLOOR 0.15–0.3s (below ~0.15s swaps strobe); READ_THRESHOLD ~0.5s; brake ≥ 4× the floor or the stop doesn’t register as a beat. The 3–6 entry guidance relaxes here — 12–18 states are legal precisely because flurry states aren’t individually read. The hard-cut discipline (lastTitle guard, instant swaps) is what lets 0.2s states render clean.
| token | range | notes |
|---|---|---|
| BASE_DURATION | 0.6–1.5s | minimum per entry regardless of length — even one-word entries get read time |
| SEC_PER_CHAR | 0.03–0.06 s/char | ≈17–33 chars/sec; uniform across the sequence so the pace reads as one engine; lean high for wide-character languages |
| HOLD_MID | 0.5–1.0s | dwell on a non-final entry; < HOLD_FINAL |
| HOLD_FINAL | 1.0–2.0s | climax dwell — must exceed HOLD_MID by a clear margin so the close reads as a beat |
| SPEED_FACTOR | 0.5–2.0 (default 1.0) | per-entry only; if every entry shares a factor, fold it into SEC_PER_CHAR |
| TAIL_PAD | 0.0–1.0s | quiet beat after the last entry; prefer 0 when the next composition owns the breath |
| CONTENT N | 3–6 entries | <3 isn’t a sequence; >6 drags (accelerating cadence relaxes this — see above) |
Reference: ../examples/messaging-multi-phrase.html.
onUpdate; the reverse search over the flat array is the whole per-frame cost.lastTitle/key guard) — per-frame textContent assignment flickers in HF render.min-height on the body element — without reservation, downstream elements (progress bar, brand) jitter as content height varies.min-height after wrapping.discrete-text-sequence (per-entry typewriter on the body) · context-sensitive-cursor (cursor color per chapter) · vertical-spring-ticker (animated word swap instead of hard cut) · scale-swap-transition (visual morph between entries).