HyperFrames
HTML is the source of truth for video. A composition is an HTML file with data-* attributes for timing, a GSAP timeline for animation, and CSS for appearance. The framework handles clip visibility, media playback, and timeline sync.
Approach
Before writing HTML, think at a high level:
- What — what should the viewer experience? Identify the narrative arc, key moments, and emotional beats.
- Structure — how many compositions, which are sub-compositions vs inline, what tracks carry what (video, audio, overlays, captions).
- Timing — which clips drive the duration, where do transitions land, what's the pacing.
- Layout — build the end-state first. See "Layout Before Animation" below.
- Animate — then add motion using the rules below.
For small edits (fix a color, adjust timing, add one element), skip straight to the rules.
Visual Identity Gate
Check in this order:
- DESIGN.md exists in the project? → Read it. Use its exact colors, fonts, motion rules, and "What NOT to Do" constraints.
- visual-style.md exists? → Read it. Apply its
style_prompt_fulland structured fields. (Note:visual-style.mdis a project-specific file.visual-styles.mdis the style library with 8 named presets — different files.) - User named a style (e.g., "Swiss Pulse", "dark and techy", "luxury brand")? → Read visual-styles.md for the 8 named presets. Generate a minimal DESIGN.md with:
## Style Prompt(one paragraph),## Colors(3-5 hex values with roles),## Typography(1-2 font families),## What NOT to Do(3-5 anti-patterns). - None of the above? → Ask 3 questions before writing any HTML:
- What's the mood? (explosive / cinematic / fluid / technical / chaotic / warm)
- Light or dark canvas?
- Any specific brand colors, fonts, or visual references? Then generate a minimal DESIGN.md from the answers.
Every composition must trace its palette and typography back to a DESIGN.md, visual-style.md, or explicit user direction. If you're reaching for #333, #3b82f6, or Roboto — you skipped this step.
For motion defaults, sizing, entrance patterns, and easing — follow house-style.md. The house style handles HOW things move. The DESIGN.md handles WHAT things look like.
Layout Before Animation
Position every element where it should be at its most visible moment — the frame where it's fully entered, correctly placed, and not yet exiting. Write this as static HTML+CSS first. No GSAP yet.
Why this matters: If you position elements at their animated start state (offscreen, scaled to 0, opacity 0) and tween them to where you think they should land, you're guessing the final layout. Overlaps are invisible until the video renders. By building the end state first, you can see and fix layout problems before adding any motion.
The process
- Identify the hero frame for each scene — the moment when the most elements are simultaneously visible. This is the layout you build.
- Write static CSS for that frame. The
.scene-contentcontainer MUST fill the full scene usingwidth: 100%; height: 100%; padding: Npx;withdisplay: flex; flex-direction: column; gap: Npx; box-sizing: border-box. Use padding to push content inward — NEVERposition: absolute; top: Npxon a content container. Absolute-positioned content containers overflow when content is taller than the remaining space. Reserveposition: absolutefor decoratives only. - Add entrances with
gsap.from()— animate FROM offscreen/invisible TO the CSS position. The CSS position is the ground truth; the tween describes the journey to get there. - Add exits with
gsap.to()— animate TO offscreen/invisible FROM the CSS position.
Example
/* scene-content fills the scene, padding positions content */
.scene-content {
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
height: 100%;
padding: 120px 160px;
gap: 24px;
box-sizing: border-box;
}
.title {
font-size: 120px;
}
.subtitle {
font-size: 42px;
}
/* Container fills any scene size (1920x1080, 1080x1920, etc).
Padding positions content. Flex + gap handles spacing. */
WRONG — hardcoded dimensions and absolute positioning:
.scene-content {
position: absolute;
top: 200px;
left: 160px;
width: 1920px;
height: 1080px;
display: flex; /* ... */
}
// Step 3: Animate INTO those positions
tl.from(".title", { y: 60, opacity: 0, duration: 0.6, ease: "power3.out" }, 0);
tl.from(".subtitle", { y: 40, opacity: 0, duration: 0.5, ease: "power3.out" }, 0.2);
tl.from(".logo", { scale: 0.8, opacity: 0, duration: 0.4, ease: "power2.out" }, 0.3);
// Step 4: Animate OUT from those positions
tl.to(".title", { y: -40, opacity: 0, duration: 0.4, ease: "power2.in" }, 3);
tl.to(".subtitle", { y: -30, opacity: 0, duration: 0.3, ease: "power2.in" }, 3.1);
tl.to(".logo", { scale: 0.9, opacity: 0, duration: 0.3, ease: "power2.in" }, 3.2);
When elements share space across time
If element A exits before element B enters in the same area, both should have correct CSS positions for their respective hero frames. The timeline ordering guarantees they never visually coexist — but if you skip the layout step, you won't catch the case where they accidentally overlap due to a timing error.
What counts as intentional overlap
Layered effects (glow behind text, shadow elements, background patterns) and z-stacked designs (card stacks, depth layers) are intentional. The layout step is about catching unintentional overlap — two headlines landing on top of each other, a stat covering a label, content bleeding off-frame.
Data Attributes
All Clips
| Attribute | Required | Values |
|---|---|---|
id | Yes | Unique identifier |
data-start | Yes | Seconds or clip ID reference ("el-1", "intro + 2") |
data-duration | Required for img/div/compositions | Seconds. Video/audio defaults to media duration. |
data-track-index | Yes | Integer. Same-track clips cannot overlap. |
data-media-start | No | Trim offset into source (seconds) |
data-volume | No | 0-1 (default 1) |
data-track-index does not affect visual layering — use CSS z-index.
Composition Clips
| Attribute | Required | Values |
|---|---|---|
data-composition-id | Yes | Unique composition ID |
data-start | Yes | Start time (root composition: use "0") |
data-duration | Yes | Takes precedence over GSAP timeline duration |
data-width / data-height | Yes | Pixel dimensions (1920x1080 or 1080x1920) |
data-composition-src | No | Path to external HTML file |
Composition Structure
Sub-compositions loaded via data-composition-src use a <template> wrapper. Standalone compositions (the main index.html) do NOT use <template> — they put the data-composition-id div directly in <body>. Using <template> on a standalone file hides all content from the browser and breaks rendering.
Sub-composition structure:
<template id="my-comp-template">
<div data-composition-id="my-comp" data-width="1920" data-height="1080">
<!-- content -->
<style>
[data-composition-id="my-comp"] {
/* scoped styles */
}
</style>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// tweens...
window.__timelines["my-comp"] = tl;
</script>
</div>
</template>
Load in root: <div id="el-1" data-composition-id="my-comp" data-composition-src="compositions/my-comp.html" data-start="0" data-duration="10" data-track-index="1"></div>
Video and Audio
Video must be muted playsinline. Audio is always a separate <audio> element:
<video
id="el-v"
data-start="0"
data-duration="30"
data-track-index="0"
src="video.mp4"
muted
playsinline
></video>
<audio
id="el-a"
data-start="0"
data-duration="30"
data-track-index="2"
src="video.mp4"
data-volume="1"
></audio>
Timeline Contract
- All timelines start
{ paused: true }— the player controls playback - Register every timeline:
window.__timelines["<composition-id>"] = tl - Framework auto-nests sub-timelines — do NOT manually add them
- Duration comes from
data-duration, not from GSAP timeline length - Never create empty tweens to set duration
Rules (Non-Negotiable)
Deterministic: No Math.random(), Date.now(), or time-based logic. Use a seeded PRNG if you need pseudo-random values (e.g. mulberry32).
GSAP: Only animate visual properties (opacity, x, y, scale, rotation, color, backgroundColor, borderRadius, transforms). Do NOT animate visibility, display, or call video.play()/audio.play().
Animation conflicts: Never animate the same property on the same element from multiple timelines simultaneously.
No repeat: -1: Infinite-repeat timelines break the capture engine. Calculate the exact repeat count from composition duration: repeat: Math.ceil(duration / cycleDuration) - 1.
Synchronous timeline construction: Never build timelines inside async/await, setTimeout, or Promises. The capture engine reads window.__timelines synchronously after page load. Fonts are embedded by the compiler, so they're available immediately — no need to wait for font loading.
Never do:
- Forget
window.__timelinesregistration - Use video for audio — always muted video + separate
<audio> - Nest video inside a timed div — use a non-timed wrapper
- Use
data-layer(usedata-track-index) ordata-end(usedata-duration) - Animate video element dimensions — animate a wrapper div
- Call play/pause/seek on media — framework owns playback
- Create a top-level container without
data-composition-id - Use
repeat: -1on any timeline or tween — always finite repeats - Build timelines asynchronously (inside
async,setTimeout,Promise) - Use
gsap.set()on clip elements from later scenes — they don't exist in the DOM at page load. Usetl.set(selector, vars, timePosition)inside the timeline at or after the clip'sdata-starttime instead. - Use
<br>in content text — forced l