Skip to main content
These are mistakes that cannot be caught by the linter. For automated checks, run npx hyperframes lint (see CLI).
The first two mistakes — animating video element dimensions and controlling media playback in scripts — are the most common causes of broken compositions. If your video looks wrong, check these first.
Symptom: Video frames stop updating, or browser performance drops severely.Cause: GSAP animating width, height, top, left directly on a <video> element can cause the browser to stop rendering frames.Before (broken):
index.html
// Animating the video element directly — causes frame rendering to stop
tl.to("#el-video", { width: 500, height: 280, top: 700, left: 1400 }, 26);
After (fixed):
index.html
<!-- Wrap the video in a div and animate the wrapper -->
<div id="pip-wrapper" style="position: absolute; width: 1920px; height: 1080px;">
  <video id="el-video" data-start="0" data-track-index="0"
         src="./assets/video.mp4" style="width: 100%; height: 100%;"></video>
</div>
index.html
// Animate the wrapper — the video fills it at 100%
tl.to("#pip-wrapper", { width: 500, height: 280, top: 700, left: 1400 }, 26);
Use a non-timed wrapper <div> for visual effects like picture-in-picture. Animate the wrapper; let the video fill it via CSS.
Symptom: Audio/video playback is out of sync, or plays when it should not.Cause: Calling video.play(), video.pause(), or setting audio.currentTime in your scripts. The framework owns all media playback.Before (broken):
index.html
// Conflicts with framework media sync
document.getElementById("el-video").play();
document.getElementById("el-audio").currentTime = 5;
After (fixed):
index.html
// Don't control media playback at all. The framework handles it.
// Use GSAP for visual animations only:
tl.to("#el-video", { opacity: 1, duration: 0.5 }, 0);
The framework reads data-start, data-media-start, and data-volume to control when and how media plays. See Compositions: Two Layers for the separation between HTML primitives and scripts.
Symptom: Video plays for a few seconds then stops. Timeline shows 8-10 seconds even though the video is minutes long.Cause: The composition duration equals the GSAP timeline duration, not data-duration on the video. If your last GSAP animation ends at 8 seconds, the composition is 8 seconds long — regardless of how long the video source is.Before (broken):
index.html
// Timeline is only 7.8s long — video cuts off after 7.8 seconds
tl.to("#lower-third", { left: -640, duration: 0.6 }, 7.2);
After (fixed):
index.html
tl.to("#lower-third", { left: -640, duration: 0.6 }, 7.2);

// Extend the timeline to 283 seconds to match the video length
tl.set({}, {}, 283);
tl.set({}, {}, TIME) adds a zero-duration tween at the specified time, extending the timeline without affecting any elements.
A quick check: run npx hyperframes compositions to see the resolved duration of each composition. If it is shorter than expected, your timeline needs extending.
Symptom: Elements are always visible, ignoring their data-start and data-duration timing.Cause: The class="clip" attribute tells the runtime to manage the element’s visibility lifecycle. Without it, the element is always rendered.Before (broken):
index.html
<!-- Missing class="clip" — this element is always visible -->
<h1 id="title" data-start="2" data-duration="5" data-track-index="0">
  Hello World
</h1>
After (fixed):
index.html
<!-- With class="clip", the runtime shows this only from 2s to 7s -->
<h1 id="title" class="clip" data-start="2" data-duration="5" data-track-index="0">
  Hello World
</h1>
The linter catches this one: npx hyperframes lint will flag timed elements missing class="clip".
Symptom: Preview stutters during scenes with images on screen. Render is slower than expected.Cause: Source images at much higher resolution than the canvas. Chrome decodes images to raw RGBA bitmaps before displaying them, and bitmap size is width × height × 4 bytes — independent of file size on disk. A 7000×5000 JPEG is 140MB decoded, even if the file is only 2MB.Displaying such an image in a 384×1080 region wastes memory and forces the compositor to resample a huge texture every frame.Before (bloated):
index.html
<!-- 7000x5000 source, ~140MB decoded -->
<img class="clip" data-start="0" data-duration="3"
     src="./assets/hero-scene.jpg" />
After (sized to the canvas):
Terminal
# Resize a batch of images to fit within 3840x3840, preserving aspect ratio
mkdir -p assets/resized
mogrify -path assets/resized -resize 3840x3840\> assets/*.jpg
index.html
<!-- ~3840x2560 source, ~40MB decoded -->
<img class="clip" data-start="0" data-duration="3"
     src="./assets/resized/hero-scene.jpg" />
Rule of thumb: source images at most 2x the canvas dimensions. For a 1920×1080 composition, 3840×2160 is already plenty. See Performance: Image sizing.
Symptom: Specific scenes drop to 5-10fps in preview. The composition is fine elsewhere.Cause: backdrop-filter: blur() on large elements, especially stacked at high radii. Each blur layer forces the compositor to sample pixels behind the element, run a blur kernel, and composite the result. Stacked layers multiply the cost.Before (expensive):
/* 8 layers per side = 16 blur passes every frame */
.pb-1 { backdrop-filter: blur(1px); }
.pb-2 { backdrop-filter: blur(2px); }
.pb-3 { backdrop-filter: blur(4px); }
.pb-4 { backdrop-filter: blur(8px); }
.pb-5 { backdrop-filter: blur(16px); }
.pb-6 { backdrop-filter: blur(32px); }
.pb-7 { backdrop-filter: blur(64px); }
.pb-8 { backdrop-filter: blur(128px); }
After (3 tuned layers):
/* Fewer passes with hand-picked radii — visually similar, much cheaper */
.pb-1 { backdrop-filter: blur(4px); }
.pb-2 { backdrop-filter: blur(16px); }
.pb-3 { backdrop-filter: blur(48px); }
Guidelines:
  • Keep stacked backdrop-filter layers to 2-3 per region
  • Avoid radii above 64px over large areas — the biggest radii dominate the total cost
  • For a static blur effect, pre-render it into a PNG once and overlay with a regular <img>
See Performance: backdrop-filter: blur() for the full breakdown.
Symptom: Animations don’t play. The composition appears static.Cause: The key used in window.__timelines must exactly match the data-composition-id attribute on the composition root element.Before (broken):
index.html
// Mismatch: HTML says "my-video", script registers "root"
// <div data-composition-id="my-video" ...>
window.__timelines["root"] = tl;
After (fixed):
index.html
// Key matches the data-composition-id attribute
// <div data-composition-id="my-video" ...>
window.__timelines["my-video"] = tl;

Debugging Checklist

When something does not work, check in this order:
  1. Run the linter: npx hyperframes lint — catches most structural issues
  2. Timeline registered? Is window.__timelines["<id>"] set? Does the key match data-composition-id?
  3. GSAP-only animations? Only animate visual properties (opacity, transform, color) — see GSAP Animation
  4. Timeline long enough? Add tl.set({}, {}, DURATION) at the end — see Timeline Duration
  5. Console errors? Open browser console — runtime errors show as [Browser:ERROR]
  6. Still stuck? See Troubleshooting for environment and rendering issues

Next Steps

Troubleshooting

Fix environment and rendering issues

GSAP Animation

Review animation rules and patterns

HTML Schema Reference

Full attribute reference and checklist

Data Attributes

Timing, media, and composition attributes