Install
That writes one file:compositions/components/beat-timeline.html.
Paste it into your composition
Opencompositions/components/beat-timeline.html and copy what is inside into your own composition.
A component has no size or duration of its own. It takes both from the composition
you paste it into.
Variables
Every one of these has a default, so the piece works untouched. Set the ones you want to change on the element:| Variable | Default | Accepts | What it does |
|---|---|---|---|
beatCount | 4 | 2 to 6, step 1 | Number of beat rows in the sequence. |
interval | 0.6 | 0.2s to 2s, step 0.05s | Base time between consecutive beat markers. |
labels | One,Two,Three,Four | string | Comma-separated beat titles, one per beat. Extra entries are ignored and missing entries fall back to Beat N. |
data-variable-values on the element that mounts it. These are the
defaults, so this behaves exactly like the preview above until you change one:
<div
data-composition-id="beat-timeline"
data-composition-src="compositions/components/beat-timeline.html"
data-variable-values='{"beatCount":4,"interval":0.6,"labels":"One,Two,Three,Four"}'
></div>
Source
beat-timeline.html
beat-timeline.html
<!doctype html>
<!--
beat-timeline -- HyperFrames video primitive (transitions / sequencing / bridge)
Concept: a visible orchestration spine made from a paused master GSAP
timeline. Each beat row is pinned to an evenly spaced named label, then
enters exactly on that mark. One mechanic, one job: bridge a sequence by
making its deterministic beat grid readable and copy-and-own-able.
Compiled-from evidence: launches-motion-vocabulary.md, "Paused, seeked
timeline orchestration" census entry (15/18 projects, 134/159 files).
Use when: a scene needs several titled beats to land in a clear order, or
when a copied composition needs a legible master-timeline sequencing spine.
Variables (declared in data-composition-variables below):
- beatCount (number, default 4): integer row count, clamped to 2..6.
- interval (number, default 0.6s): base spacing between named beat labels,
clamped to 0.2..2s before any short-duration compression.
- labels (string): comma-separated row titles. Missing entries fall back
to "Beat N" and extra entries are ignored.
Envelope (holdable orchestration adaptation, never gsap.timeScale()):
ENTRANCE_SPAN = (beatCount - 1) * interval + ROW_IN_DURATION.
HOLD = max(0, D - (ENTRANCE_SPAN + OUT_BASE)); every landed row remains
fully visible and deliberately still during this elastic phase.
OUT_BASE = 0.40s; all rows fade together with power2.in at D - OUT_BASE.
If D is shorter than ENTRANCE_SPAN + OUT_BASE, interval and each row's
entrance duration scale down together so the final row still lands before
OUT. Only a host duration at or below OUT_BASE also compresses OUT, which
keeps the full lifecycle inside an otherwise impossible time box.
Sync points: beat labels are fixed offsets inside IN. Every label owns one
declared SFX cue at the same offset, never inside elastic HOLD. Beats use
"beat-tick" and the last beat uses "beat-tick-final". The primitive only
dispatches bubbling hf:sfx events; scene-level audio mixing owns playback.
Mount contract: this file is a mountable sub-composition. The runtime clones
only <template> contents, so all live styles, markup, and scripts are inside
the template. #root fills the host-owned box with no data-width/data-height,
and all internal geometry is based on its own cqw/cqh container. Variables
come from window.__hyperframes.getVariables(), because document.documentElement
belongs to the host after mounting.
-->
<html
lang="en"
data-composition-variables='[
{ "id": "beatCount", "type": "number", "role": "content", "label": "Beat count", "description": "Number of beat rows in the sequence.", "default": 4, "min": 2, "max": 6, "step": 1 },
{ "id": "interval", "type": "number", "role": "layout", "label": "Interval", "description": "Base time between consecutive beat markers.", "default": 0.6, "min": 0.2, "max": 2, "step": 0.05, "unit": "s" },
{ "id": "labels", "type": "string", "role": "content", "label": "Labels", "description": "Comma-separated beat titles, one per beat. Extra entries are ignored and missing entries fall back to Beat N.", "default": "One,Two,Three,Four" }
]'
>
<head>
<meta charset="UTF-8" />
<title>Beat Timeline</title>
<!-- Metadata only. The loader reads variable declarations from <html>,
then discards everything outside <template> during mounting. -->
</head>
<body>
<template>
<div
id="root"
data-composition-id="beat-timeline"
data-start="0"
data-duration="5.5"
data-fps="30"
>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
/* Root is styled by ID for mounted CSS scoping. It owns no stage
dimensions, only the host box and the container-unit basis. */
#root {
--ease-standard: cubic-bezier(0.215, 0.61, 0.355, 1);
--ease-exit: cubic-bezier(0.55, 0.055, 0.675, 0.19);
position: absolute;
inset: 0;
container-type: size;
isolation: isolate;
overflow: hidden;
background: var(--bg, Canvas);
color: var(--fg, CanvasText);
font-family: var(--font-body, system-ui, sans-serif);
}
.bt-clip {
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.bt-stack {
width: 84cqw;
display: flex;
flex-direction: column;
gap: 1.4cqh;
}
/* EDIT ZONE: row geometry only. Motion timing is owned by the script's
RETIME RANGE below, and theme paint remains token-driven here. */
.bt-row {
min-height: 10cqh;
display: grid;
grid-template-columns: 6cqw 1fr;
align-items: center;
column-gap: 2.6cqw;
padding: 1.4cqh 2.4cqw;
border: 0.12cqw solid var(--border, currentColor);
border-radius: var(--radius, 2cqh);
background: var(--surface, Canvas);
box-shadow: 0 1.2cqh 4cqh color-mix(in srgb, var(--bg, Canvas) 52%, transparent);
}
.bt-index {
width: 6cqw;
height: 6cqw;
display: grid;
place-items: center;
border-radius: 50%;
background: var(--brand, currentColor);
color: var(--bg, Canvas);
font-family: var(--font-display, system-ui, sans-serif);
font-size: 1.8cqw;
font-weight: 750;
line-height: 1;
}
.bt-copy {
min-width: 0;
}
.bt-title {
overflow: hidden;
color: var(--fg, CanvasText);
font-family: var(--font-display, system-ui, sans-serif);
font-size: min(4.4cqw, 5.2cqh);
font-weight: 700;
line-height: 1.04;
letter-spacing: -0.08cqw;
text-overflow: ellipsis;
white-space: nowrap;
}
.bt-subtitle {
margin-top: 0.7cqh;
color: var(--muted, currentColor);
font-size: min(1.8cqw, 2.2cqh);
font-weight: 550;
line-height: 1.15;
letter-spacing: 0.04cqw;
}
</style>
<div
id="beat-timeline-clip"
class="bt-clip clip"
data-start="0"
data-duration="5.5"
data-track-index="0"
>
<div class="bt-stack" aria-label="Beat timeline"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
(function () {
"use strict";
var root = document.getElementById("root");
// Literal by contract: mounting strips data-composition-id from the
// flattened inner root, so reading the attribute post-mount would
// register under null and fail the timeline lookup.
var compositionId = "beat-timeline";
var stack = root.querySelector(".bt-stack");
// EDIT ZONE: declarations live on <html>; this runtime call is the
// single owner of declared defaults plus per-instance overrides.
var vars =
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {};
var parsedBeatCount = Number(vars.beatCount);
// INVARIANT: exactly 2..6 integer rows reach the DOM and timeline.
var beatCount = Math.max(
2,
Math.min(6, Math.round(Number.isFinite(parsedBeatCount) ? parsedBeatCount : 4)),
);
var parsedInterval = Number(vars.interval);
// INVARIANT: invalid timing falls back once, valid timing clamps to
// the declared range before duration elasticity is calculated.
var interval = Math.max(
0.2,
Math.min(2, Number.isFinite(parsedInterval) ? parsedInterval : 0.6),
);
// INVARIANT: labels is always a string; blank or missing entries
// fall back per row, while authored extra entries stay unused.
var labelsValue = vars.labels == null ? "One,Two,Three,Four" : String(vars.labels);
var suppliedLabels = labelsValue.split(",").map(function (label) {
return label.trim();
});
var parsedDuration = parseFloat(root.dataset.duration || "5.5");
// INVARIANT: the lifecycle remains finite even under a malformed
// host duration. The authored and demo durations are both 5.5s.
var duration = Math.max(0.001, Number.isFinite(parsedDuration) ? parsedDuration : 5.5);
// RETIME RANGE: these values own the whole pacing decision. The
// interval and row entrance compress together, never via timeScale.
var ROW_IN_BASE = 0.4;
var OUT_BASE = 0.4;
var baseEntranceSpan = (beatCount - 1) * interval + ROW_IN_BASE;
var OUT = duration <= OUT_BASE ? duration / 2 : OUT_BASE;
var entranceBudget = duration - OUT;
var scale = baseEntranceSpan > entranceBudget ? entranceBudget / baseEntranceSpan : 1;
var timedInterval = interval * scale;
var ROW_IN = ROW_IN_BASE * scale;
var ENTRANCE_SPAN = (beatCount - 1) * timedInterval + ROW_IN;
var HOLD = Math.max(0, duration - (ENTRANCE_SPAN + OUT));
var OUT_START = ENTRANCE_SPAN + HOLD;
function fireSfx(id, t) {
root.dispatchEvent(
new CustomEvent("hf:sfx", { detail: { id: id, t: t }, bubbles: true }),
);
}
var rows = [];
for (var i = 0; i < beatCount; i += 1) {
var title = suppliedLabels[i] || "Beat " + (i + 1);
var row = document.createElement("div");
row.className = "bt-row";
row.innerHTML =
'<div class="bt-index" aria-hidden="true"></div>' +
'<div class="bt-copy"><div class="bt-title"></div><div class="bt-subtitle"></div></div>';
row.querySelector(".bt-index").textContent = String(i + 1).padStart(2, "0");
row.querySelector(".bt-title").textContent = title;
row.querySelector(".bt-subtitle").textContent =
"Cue " +
String(i + 1).padStart(2, "0") +
" · " +
(i * timedInterval).toFixed(2) +
"s";
stack.appendChild(row);
rows.push(row);
}
// Explicit entrance and exit endpoints make a direct seek to t=0
// correct without ever playing the timeline.
gsap.set(rows, { opacity: 0, y: "2cqh", scale: 0.98 });
var tl = gsap.timeline({ paused: true });
// ORCHESTRATION SPINE: every row tween and its declared cue are
// pinned to the named label, never to a duplicated numeric offset.
for (var beatIndex = 0; beatIndex < beatCount; beatIndex += 1) {
var labelName = "beat" + beatIndex;
var cueTime = beatIndex * timedInterval;
var cueId = beatIndex === beatCount - 1 ? "beat-tick-final" : "beat-tick";
tl.addLabel(labelName, cueTime);
tl.to(
rows[beatIndex],
{ opacity: 1, y: "0cqh", scale: 1, duration: ROW_IN, ease: "power2.out" },
labelName,
);
// SFX CUE (IN, beat N): one scene-mixed cue at this beat label.
tl.call(fireSfx, [cueId, cueTime], labelName);
}
// HOLD is deliberately still. OUT accelerates every landed row away
// together, asymmetrically paired with the power2.out entrances.
tl.to(rows, { opacity: 0, duration: OUT, ease: "power2.in" }, OUT_START);
tl.seek(0);
window.__timelines = window.__timelines || {};
window.__timelines[compositionId] = tl;
})();
</script>
</div>
</template>
</body>
</html>
orchestration transitions sequencing bridge timeline.