
Slideshow
- 113k installs
- 38.1k repo stars
- Updated July 28, 2026
- heygen-com/hyperframes
hyperframes-slideshow is an agent skill for authoring interactive HyperFrames slideshow compositions with discrete slide navigation and branching paths.
About
Agent skill for authoring interactive HyperFrames slideshow compositions. Enables developers to build presentations and pitch decks with discrete slide navigation, fragment reveals, and branching paths.
- Interactive slideshow composition authoring
- Discrete slide navigation with keyboard/click controls
- Fragment reveals and branching navigation paths
Slideshow by the numbers
- 112,863 all-time installs (skills.sh)
- +13,235 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #8 of 2,277 Frontend Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
slideshow capabilities & compatibility
- Use cases
- ui design
- Pricing
- Free
npx skills add https://github.com/heygen-com/hyperframes --skill slideshowAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 113k |
|---|---|
| repo stars | ★ 38.1k |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 28, 2026 |
| Repository | heygen-com/hyperframes ↗ |
How do you build a HyperFrames slideshow deck?
Create interactive slideshow presentations with HyperFrames
Who is it for?
Building interactive presentations and pitch decks with HyperFrames
Skip if: Static PowerPoint exports, plain Reveal.js Markdown decks, or video-only renders without HyperFrames compositions.
When should I use this skill?
Developer needs to create interactive slideshow or presentation experience
What you get
HyperFrames composition, JSON slide island, and SlideshowController-navigable deck
- JSON slide island
- GSAP scene timelines
- Navigable slideshow composition
Files
Slideshow authoring contract
A HyperFrames slideshow is a normal HyperFrames composition — scenes, clips, GSAP timelines — with one extra ingredient: a JSON island that declares which scenes are slides and how they connect. The player's SlideshowController reads the island and turns the continuous GSAP timeline into a discrete, navigable deck.
Read `/hyperframes-core` first for the base composition contract (clips, tracks, data-* attributes, determinism rules). This skill covers only what is new: the island schema, slide writing rules, fragments, branching, validation, and the wrapping component.
---
The two pieces
1. Scenes — declared the normal way
Every slide is backed by a scene. Declare scenes with data-composition-id, data-start, data-duration, and data-label:
<div
data-composition-id="problem"
data-start="0"
data-duration="8"
data-label="The problem"
data-width="1920"
data-height="1080"
>
<!-- clips go here -->
</div>Branch slides (reachable only via a hotspot, excluded from the main line) are declared exactly the same way — they just appear only in a slideSequences entry in the island, not in the main slides array.
2. The JSON island — one script block per composition
Add exactly one <script type="application/hyperframes-slideshow+json"> block to the composition HTML. It holds all slideshow metadata:
<script type="application/hyperframes-slideshow+json">
{
"slides": [...],
"slideSequences": [...]
}
</script>The island is the single source of truth for slide order, notes, fragment hold-points, hotspots, and branch sequences. Keep it near the top of the <body>, before the scene divs, so it is easy to find.
---
Schema
SlideshowManifest (the top-level island object)
{
"slides": [
/* SlideRef[] — the main line, in order */
],
"slideSequences": [
/* SlideSequence[] — off-line branch sequences */
]
}SlideRef
{
"sceneId": "problem",
"notes": "Lead with the pain, not the company.",
"fragments": [3.5, 5.2, 7.0],
"hotspots": [
/* SlideHotspot[] */
],
"ttsScript": null,
"ttsAudioUrl": null,
"ttsDurationMs": null
}| Field | Required | Notes |
|---|---|---|
sceneId | yes | Must match a scene's data-composition-id exactly. The lint rule resolves both data-composition-id and .clip[id]. |
notes | no | Presenter-only text. Never shown to the audience. |
fragments | no | Array of times (seconds) within the slide's [start, end] range — see Fragments below. |
hotspots | no | Interactive overlays that trigger a branch — see Branching below. |
startTime | no | Optional. Override the matched scene's time bounds; defaults to the scene's start/end. |
endTime | no | Optional. Override the matched scene's time bounds; defaults to the scene's start/end. |
ttsScript, ttsAudioUrl, ttsDurationMs | no | Reserved. Schema fields exist but TTS playback is not yet wired. Omit unless you are pre-populating for a future build. |
SlideHotspot
{
"id": "h1",
"label": "How did we calculate this?",
"target": "market-deep-dive",
"region": { "x": 60, "y": 10, "w": 35, "h": 20 }
}| Field | Required | Notes |
|---|---|---|
id | yes | Unique within the slide. |
label | yes | Tooltip / button text shown to the audience. |
target | yes | Must match a SlideSequence.id in slideSequences. |
region | no | Percentage-of-slide bounding box: {x, y, w, h} in 0–100. Omit to render the hotspot as a full-slide labeled button instead. |
SlideSequence
{
"id": "market-deep-dive",
"label": "Market sizing methodology",
"slides": [{ "sceneId": "mkt-1" }, { "sceneId": "mkt-2" }]
}slides inside a sequence uses the same SlideRef shape as the main line. Fragments and nested hotspots are allowed.
---
Slide writing rules
These are hard constraints, not suggestions. A slide that violates them will be outright replaced when a reviewer sees it.
- Headline is a complete-sentence claim, not a label. Write "SMBs spend 14 hours/week on manual scheduling" not "Scheduling problem". The sentence should stand alone if the visual is ignored.
- One idea + one visual per slide. If you are tempted to add a second bullet cluster or a second chart, split the slide.
- Lead with the punchline. The strongest point goes first — on the slide and in the deck order. Investors read left-to-right, top-to-bottom, and they stop.
- Bottom-up market sizing only. Never write "$50B TAM" without showing the math. Build from unit economics up: accounts × ACV, or transactions × take-rate.
- Font minimum 30pt equivalent. At 1920×1080, a headline is 72–96px; body copy is 48px. Never go below 40px for any text the audience must read.
---
Fragments: reveal hold-points within a slide
A fragment is a time (in seconds) within a slide's [start, end] range where the controller pauses before the next reveal.
How it works:
1. Player enters the slide — seeks to start, then plays. 2. Controller pauses at fragments[0]. The first element's GSAP entrance has just landed. 3. User presses Next (or →) — plays to fragments[1], pauses again. 4. After the last fragment, Next plays to slide.end and holds. 5. Next again advances to the next slide.
Fragment times must be strictly inside [start, end]. The lint rule rejects fragments outside that range.
Fragment times are absolute composition-timeline positions — the same coordinate space as data-start — not offsets relative to the scene's start.
Each fragment is a play-to-and-hold, not a seek jump — so every element that enters between the previous hold-point and this one plays its GSAP entrance animation. Design the clip entrance animations to work as sequential reveals.
---
Branching: hotspots and slide sequences
Branch slides are real scenes in the same composition timeline. They are listed only under slideSequences and are excluded from main-line navigation — the player never visits them unless a hotspot fires.
Navigation model:
- Clicking a hotspot pushes
{sequenceId, slideIndex: 0}onto the nav stack and enters the branch's first slide. - back() pops the stack and returns to the exact parent slide (the one that held the hotspot).
- backToMain() clears the entire stack and returns to the root slide.
- Breadcrumb renders from the stack:
Main deck › Market sizing methodology › Slide 2. - The slide counter inside a branch is scoped to that sequence (
1 of 2, not the main-deck total).
What to avoid:
- Do not add branch scene IDs to the main
slidesarray. They must appear only inside aslideSequencesentry. The lint rule flags overlap. - Branch scenes are included in the continuous timeline, so a naive linear video export would include them. Export reads main-line slides only (deferred; flagged in the spec).
---
Worked example: 3-slide deck with fragments and a branch
Scene HTML (skeleton)
<body style="margin: 0">
<script type="application/hyperframes-slideshow+json">
{
"slides": [
{
"sceneId": "hook",
"notes": "Open with the stat. Pause on the $40B number."
},
{
"sceneId": "problem",
"notes": "Walk through each pain point one at a time.",
"fragments": [11.0, 15.0],
"hotspots": [
{
"id": "h1",
"label": "Where does the $40B figure come from?",
"target": "market-detail",
"region": { "x": 55, "y": 60, "w": 40, "h": 20 }
}
]
},
{
"sceneId": "solution",
"notes": "One sentence: what we do and who it is for."
}
],
"slideSequences": [
{
"id": "market-detail",
"label": "Market sizing methodology",
"slides": [{ "sceneId": "mkt-math", "notes": "Bottom-up: 2.3M SMBs × $17k ACV." }]
}
]
}
</script>
<!-- Slide 1 — hook -->
<div
data-composition-id="hook"
data-start="0"
data-duration="6"
data-label="The hook"
data-width="1920"
data-height="1080"
style="position: relative; width: 1920px; height: 1080px; overflow: hidden; background: #0a0a0a"
>
<section
class="clip"
data-start="0"
data-duration="6"
data-track-index="1"
style="position: absolute; inset: 0; display: grid; place-items: center"
>
<h1 id="hook-headline" style="font-size: 80px; color: #fff; font-family: sans-serif">
SMBs lose $40B/year to manual scheduling
</h1>
</section>
</div>
<!-- Slide 2 — problem (3 fragments) -->
<div
data-composition-id="problem"
data-start="6"
data-duration="15"
data-label="The problem"
data-width="1920"
data-height="1080"
style="position: relative; width: 1920px; height: 1080px; overflow: hidden; background: #0a0a0a"
>
<section
class="clip"
data-start="6"
data-duration="15"
data-track-index="1"
style="position: absolute; inset: 0; padding: 120px 160px; box-sizing: border-box"
>
<h2 id="pain-headline" style="font-size: 64px; color: #fff; font-family: sans-serif">
Three gaps operators can not close
</h2>
<p id="pain-1" style="font-size: 48px; color: #ccc; opacity: 0; font-family: sans-serif">
No-shows cost 23% of booked revenue
</p>
<p id="pain-2" style="font-size: 48px; color: #ccc; opacity: 0; font-family: sans-serif">
Manual reminders take 4h/week per staff
</p>
<p id="pain-3" style="font-size: 48px; color: #ccc; opacity: 0; font-family: sans-serif">
Rescheduling friction drives 40% churn
</p>
</section>
</div>
<!-- Slide 3 — solution -->
<div
data-composition-id="solution"
data-start="21"
data-duration="8"
data-label="The solution"
data-width="1920"
data-height="1080"
style="position: relative; width: 1920px; height: 1080px; overflow: hidden; background: #0a0a0a"
>
<section
class="clip"
data-start="21"
data-duration="8"
data-track-index="1"
style="position: absolute; inset: 0; display: grid; place-items: center"
>
<h2 id="solution-headline" style="font-size: 72px; color: #fff; font-family: sans-serif">
Acme automates scheduling for service SMBs — no-shows down 80% in 90 days
</h2>
</section>
</div>
<!-- Branch slide — excluded from main line -->
<div
data-composition-id="mkt-math"
data-start="29"
data-duration="7"
data-label="Market math"
data-width="1920"
data-height="1080"
style="position: relative; width: 1920px; height: 1080px; overflow: hidden; background: #111"
>
<section
class="clip"
data-start="29"
data-duration="7"
data-track-index="1"
style="position: absolute; inset: 0; display: grid; place-items: center"
>
<p id="mkt-formula" style="font-size: 56px; color: #fff; font-family: sans-serif">
2.3M SMBs × $17k ACV = $39B serviceable market
</p>
</section>
</div>
<script>
window.__timelines = window.__timelines || {};
// Slide 2 fragment entrance animations
gsap.registerPlugin(); // load any plugins before use
const tl = gsap.timeline({ paused: true });
window.__timelines["problem"] = tl;
// Insert positions are absolute composition-timeline times (same as data-start / fragment values).
tl.from("#pain-1", { opacity: 0, y: 20, duration: 0.4 }, 11.0);
tl.from("#pain-2", { opacity: 0, y: 20, duration: 0.4 }, 15.0);
// pain-3 lands at end of slide
tl.from("#pain-3", { opacity: 0, y: 20, duration: 0.4 }, 13.0);
</script>
</body>Key points in the example
- The island
sceneIdvalues ("hook","problem","solution","mkt-math") exactly matchdata-composition-idvalues on scene divs. mkt-mathappears only inslideSequences— it is never in the top-levelslidesarray.- Fragment times (
11.0,15.0) are within theproblemscene's[6, 21]range (times are absolute composition-timeline positions). - The hotspot
region(x: 55, y: 60, w: 40, h: 20) positions the clickable area in the lower-right quadrant of the problem slide. - GSAP timelines are registered on
window.__timelinesand are paused — the HyperFrames engine drives playback; do not call.play()at construction time.
---
Wrapping component
Wrap the composition in <hyperframes-slideshow> around <hyperframes-player> in any embedding context:
<hyperframes-slideshow>
<hyperframes-player src="deck.html"></hyperframes-player>
</hyperframes-slideshow><hyperframes-slideshow> provides the navigation chrome (Prev / Next buttons, progress dots, breadcrumb, counter), keyboard handling (← / → and Space / Backspace), touch swipe, and hotspot overlays.
Presenter mode: the Present button calls window.open('?mode=audience') for a fullscreen audience window; the originating tab becomes the presenter view (current slide reduced, next-slide preview, notes, elapsed timer). Both windows sync via BroadcastChannel('hf-slideshow').
---
Running a slideshow standalone (interim)
The durable answer is engine-hosted: hyperframes preview --slideshow / studio present mode will host the composition over the real HyperFrames engine, which drives seek-timelines, owns the gesture frame, and reads the island from the composition. That path is coming; prefer it once it ships.
Until then, standalone demos (a composition opened via the bare player bundle in a browser, without the engine) require workarounds for four gaps: the player does not drive GSAP seek-timelines, the island must be duplicated into the wrapper, audio must live in the parent frame, and animations must be self-driving. These patterns are documented in:
skills/slideshow/references/standalone-harness.mdDo not treat the patterns there as the blessed model — they exist only to bridge the gap until the engine-hosted path lands.
---
Validation
After authoring or editing a slideshow composition, run:
npx hyperframes lintThe slideshow lint rule checks:
- Every
slide.sceneIdresolves to an existing scene (bydata-composition-id). - Every
hotspot.targetreferences a definedslideSequenceid. - Fragment times fall within each slide's
[start, end]range. - No two main-line slides overlap in time.
Fix all violations before previewing. A composition that fails lint will not parse correctly in the player.
Standalone HyperFrames Slideshow Harness
1. Interim framing — why this exists
These patterns are a temporary workaround for standalone demos. The durable solution is engine-hosted: a future hyperframes preview --slideshow / studio present mode will host the composition over the real HyperFrames engine, which drives seek-timelines frame-by-frame, owns the gesture frame, and reads the slideshow island directly from the composition. When that path ships, most of what follows collapses.
Until then, a standalone slideshow opened via the bare player bundle must work around four facts:
1. The bare <hyperframes-player> does not drive GSAP/Three seek-timelines frame-by-frame — only the engine does. Animations that wait to be seeked stay at frame 0. 2. <hyperframes-slideshow> reads the slideshow island from its own innerHTML (the wrapper element), not from the composition the player loads. The island must be duplicated into the wrapper. 3. The composition runs in the player's iframe; user keypresses and pointer events land on the parent page. Any gesture-gated API (Audio, AudioContext) must live in the parent — an iframe without its own user activation is permanently autoplay-blocked. 4. Autoplay, Three.js, and entrance animations must be self-driving because the engine is not present.
Do not treat these as the blessed authoring model. When the engine-hosted path ships, compositions authored the normal way will just work.
Living reference implementations:
registry/examples/airbnb-deck/index.html+demo.html— full pattern set (Three.js, fragments, SFX, branch slide)registry/examples/startup-pitch/index.html— minimal version (no 3D), good starting point
---
2. The parent wrapper (demo.html)
The parent page hosts the two dist bundles, wraps the components, duplicates the island, and owns all audio.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Deck — Slideshow Demo</title>
<!--
Load both bundles from packages/player/dist.
The global builds register <hyperframes-player> and <hyperframes-slideshow>
as custom elements — no import map needed.
-->
<script src="../../../packages/player/dist/hyperframes-player.global.js"></script>
<script src="../../../packages/player/dist/slideshow/hyperframes-slideshow.global.js"></script>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
background: #111;
}
</style>
</head>
<body>
<!--
tabindex="0" is critical — <hyperframes-slideshow> binds keydown
(ArrowLeft/Right, Space, Backspace) to itself. Without tabindex the
element cannot receive focus and arrow keys are dead.
-->
<hyperframes-slideshow
tabindex="0"
style="display: block; position: relative; width: 100vw; height: 100vh"
>
<hyperframes-player
style="position: absolute; inset: 0"
src="index.html"
></hyperframes-player>
<!--
DUPLICATED ISLAND — keep in sync with the island inside index.html.
<hyperframes-slideshow> reads from its own innerHTML, not from the
composition the player loads. Every time slides/fragments/hotspots/
sequences change in index.html, update this copy too.
-->
<script type="application/hyperframes-slideshow+json">
{
"slides": [
{ "sceneId": "scene-one", "notes": "..." },
{
"sceneId": "scene-two",
"notes": "...",
"fragments": [8.3, 8.6]
}
],
"slideSequences": [
{
"id": "branch-one",
"label": "Branch label",
"slides": [{ "sceneId": "branch-scene", "notes": "..." }]
}
]
}
</script>
</hyperframes-slideshow>
<!-- Audio player lives here — see Section 6 -->
</body>
</html>---
3. Playhead-driven scene visibility
Without the engine, scenes are driven by a root GSAP timeline that the composition manages on its own clock. The visibility controller reads window.__timelines.root.time() via that timeline's onUpdate callback and sets opacity accordingly. Only the active scene is visible.
The key insight: scene backgrounds must be transparent (not opaque) if you want a Three.js canvas behind them; the body/html background and scene inline background set the visual fill.
<!-- In index.html (composition) -->
<!-- Shared scene CSS — all scenes start hidden -->
<style>
.scene-frame {
position: absolute;
top: 0;
left: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
opacity: 0; /* hidden at rest — visibility controller shows the active one */
pointer-events: none; /* inactive scenes must not swallow events */
}
</style>
<!--
content-visible-at-rest: mark the first scene's elements with their
final non-hidden state so the deck is not blank before the controller
fires. The controller calls updateVisibility(0) synchronously on load.
If using Three.js canvas behind scenes, set background: transparent
here and let the 3D canvas + body color supply the fill.
-->
<div
id="scene-cover"
class="scene-frame clip"
data-composition-id="cover"
data-start="0"
data-duration="9"
style="background: transparent"
>
<!-- content here -->
</div>
<div
id="scene-problem"
class="scene-frame clip"
data-composition-id="problem"
data-start="9"
data-duration="9"
style="background: transparent"
>
<!-- content here -->
</div>
<!-- Root timeline — spans the full composition duration -->
<script>
(function () {
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
// A single to() for the full duration establishes the seekable range
tl.to({}, { duration: 108 }); // replace 108 with your total seconds
window.__timelines["root"] = tl;
})();
</script>
<!-- Visibility controller -->
<script>
(function () {
var scenes = [
{ id: "scene-cover", start: 0, end: 9 },
{ id: "scene-problem", start: 9, end: 18 },
// ... all scenes including branch scenes
];
var lastActiveId = null;
function updateVisibility(t) {
for (var i = 0; i < scenes.length; i++) {
var s = scenes[i];
var el = document.getElementById(s.id);
if (!el) continue;
var active = t >= s.start && t < s.end;
el.style.opacity = active ? "1" : "0";
el.style.pointerEvents = active ? "auto" : "none";
if (active && lastActiveId !== s.id) {
lastActiveId = s.id;
fireEntrance(el); // see Section 4
}
}
// fragment reveals here — see Section 4
}
window.__hfSetTime = updateVisibility;
// Show first slide immediately — avoids blank on load
updateVisibility(0);
// Hook the root timeline so every seek drives visibility
var root = window.__timelines && window.__timelines["root"];
if (root) {
root.eventCallback("onUpdate", function () {
updateVisibility(root.time());
});
}
})();
</script>---
4. Imperative entrances on slide-activate
The engine-hosted path drives GSAP seek-timelines frame by frame. Without it, seek-timeline tweens never fire. Instead, fire imperative gsap.from() calls each time a scene becomes active — these run on GSAP's own ticker and are independent of any playhead.
Fragment reveals use playhead-crossing: the visibility controller checks whether the playhead has passed each fragment's hold-time and fires an animation on the first crossing. Bunch fragment hold-times near the scene start (within the first 300–500 ms of the scene) so successive ArrowRight presses feel like snappy sequential reveals rather than long waits.
// --- Entrance animations ---
function fireEntrance(sceneEl) {
// [data-anim] marks elements that should entrance on slide-activate.
// Add data-anim to eyebrows, headlines, subheads, and card grids.
var animEls = sceneEl.querySelectorAll("[data-anim]");
if (!animEls.length) return;
gsap.from(animEls, {
opacity: 0,
y: 28,
duration: 0.4,
stagger: 0.07,
ease: "power2.out",
overwrite: true, // cancel any in-flight animation on rapid slide changes
});
}
// --- Fragment reveals ---
// Fragment config: times in absolute composition timeline seconds,
// bunched near the scene start for snappy successive reveals.
var fragments = [
{ time: 9.3, id: "prob-item1", revealed: false },
{ time: 9.6, id: "prob-item2", revealed: false },
];
function revealFragment(id) {
var el = document.getElementById(id);
if (!el) return;
gsap.fromTo(
el,
{ opacity: 0, x: -24 },
{ opacity: 1, x: 0, duration: 0.35, ease: "power2.out", overwrite: true },
);
}
// Inside updateVisibility(t):
for (var f = 0; f < fragments.length; f++) {
if (!fragments[f].revealed && t >= fragments[f].time) {
fragments[f].revealed = true;
revealFragment(fragments[f].id);
}
}
// On problem scene re-entry, reset all fragment states:
if (active && lastActiveId !== s.id && s.id === "scene-problem") {
for (var f = 0; f < fragments.length; f++) {
fragments[f].revealed = false;
var pEl = document.getElementById(fragments[f].id);
if (pEl) gsap.set(pEl, { opacity: 0, clearProps: "transform" });
}
}Fragment items start with opacity: 0 in CSS. The visibility controller reveals them; the entrance driver does not touch them until crossing.
---
5. The scenes bootstrap postMessage
<hyperframes-slideshow> must know each scene's time range to map a sceneId to a playhead position. Without the engine injecting this at runtime, the composition must post it manually after load.
Post the manifest from the composition (index.html), not the parent wrapper:
// In index.html — post after a brief delay so the parent frame has settled
(function () {
var FPS = 30;
var totalSeconds = 108; // match your composition's data-duration
var totalFrames = totalSeconds * FPS;
var scenes = [
// EVERY scene — including branch scenes — must appear here.
// id must match data-composition-id; start/duration in seconds.
{ id: "cover", start: 0, duration: 9 },
{ id: "problem", start: 9, duration: 9 },
{ id: "solution", start: 18, duration: 9 },
// ... all main-line scenes ...
// branch scene — listed last, NOT in main slides array in the island
{ id: "market-sizing", start: 99, duration: 9 },
];
function postTimeline() {
parent.postMessage(
{
source: "hf-preview",
type: "timeline",
durationInFrames: totalFrames,
scenes: scenes,
},
"*",
);
}
// ~300ms delay after load to let the parent settle
if (document.readyState === "complete") {
setTimeout(postTimeline, 300);
} else {
window.addEventListener("load", function () {
setTimeout(postTimeline, 300);
});
}
})();Omitting any scene (including branch scenes) from this manifest means the slideshow component cannot seek to it. Include every scene declared in the HTML, even scenes only reachable via a hotspot.
---
6. Audio/SFX — built-in mute control via <hyperframes-slideshow sound>
Audio must live in the parent page, not the composition iframe. Browsers enforce user-activation for AudioContext and HTMLAudioElement.play() — an iframe without its own activation (i.e., the user never clicked inside it) is permanently autoplay-blocked. The user's keypress lands on the parent, so the parent is the only frame that can get the activation token.
Mute toggle — built-in chrome control
Add the sound boolean attribute to <hyperframes-slideshow> in demo.html. The component renders a speaker/speaker-muted SVG button as the leftmost item in the nav capsule, styled identically to the prev/next ghost buttons. No separate mute button in the composition.
<hyperframes-slideshow tabindex="0" sound style="..."> ... </hyperframes-slideshow>The component:
- Tracks
mutedstate (defaultfalse); exposes amutedgetter - Reflects to a
data-hf-mutedattribute on the host when muted - Dispatches
CustomEvent("hf-sound", { detail: { muted }, bubbles: true, composed: true })on every toggle
The parent audio player gates on the hf-sound event:
var muted = false;
var slideshow = document.querySelector("hyperframes-slideshow");
if (slideshow) {
slideshow.addEventListener("hf-sound", function (e) {
muted = e.detail && e.detail.muted === true;
});
}
// In message handler:
if (muted) return; // skip playIf sound is not present on <hyperframes-slideshow> (decks without audio), the mute control is hidden — the capsule shows only nav.
Composition: post cues unconditionally
The composition posts sfx cues unconditionally — it does not track mute state. The parent gates on muted:
In the composition (index.html):
// Post an sfx cue at transition points — unconditionally.
// The parent audio player gates on the slideshow component's mute state.
function playSfx(name) {
try {
parent.postMessage({ type: "hf-sfx", name: name }, "*");
} catch (e) {}
}
// Fire at scene transitions:
// playSfx("advance") — moving to the next main-line slide
// playSfx("back") — returning from a branch
// playSfx("branch-enter") — entering a branch
// playSfx("fragment") — a fragment item is revealedDo NOT add a mute button inside the composition. The #sfx-mute coral button pattern is removed; the nav capsule in the parent chrome owns mute.
In the parent (demo.html):
<script>
(function () {
// Audio elements are preloaded here, in the frame that receives user gestures.
var clips = {
advance: new Audio("sfx/advance.mp3"),
fragment: new Audio("sfx/fragment.mp3"),
"branch-enter": new Audio("sfx/branch-enter.mp3"),
back: new Audio("sfx/back.mp3"),
};
clips.advance.volume = 0.45;
clips.fragment.volume = 0.4;
clips["branch-enter"].volume = 0.4;
clips.back.volume = 0.4;
Object.keys(clips).forEach(function (k) {
clips[k].preload = "auto";
});
// Track mute state from the slideshow component's hf-sound event.
var muted = false;
var slideshow = document.querySelector("hyperframes-slideshow");
if (slideshow) {
slideshow.addEventListener("hf-sound", function (e) {
muted = e.detail && e.detail.muted === true;
});
}
var unlocked = false;
function unlock() {
if (unlocked) return;
unlocked = true;
// Prime each clip: play muted then immediately pause/reset.
// This moves the clip into the "allowed" state so later plays are instant.
Object.keys(clips).forEach(function (name) {
var el = clips[name];
var v = el.volume;
el.volume = 0;
el.play()
.then(function () {
el.pause();
el.currentTime = 0;
el.volume = v;
})
.catch(function () {
el.volume = v;
});
});
}
// Unlock on the first user gesture in the parent frame.
window.addEventListener("keydown", unlock, true);
window.addEventListener("pointerdown", unlock, true);
window.addEventListener("click", unlock, true);
window.addEventListener("message", function (e) {
var d = e.data;
if (!d || d.type !== "hf-sfx") return;
// Gate on mute state — the component owns this.
if (muted) return;
var el = clips[d.name];
if (!el || !unlocked) return;
try {
el.currentTime = 0;
el.play().catch(function () {});
} catch (err) {}
});
})();
</script>Sourcing SFX files: use the HeyGen MCP search_audio_sounds tool with type=sound_effects and keywords like "whoosh", "click", "transition". Download the results to a local sfx/ directory next to demo.html and reference them by relative path. Do not fetch SFX at render time — the HyperFrames determinism rule forbids runtime network requests; pre-download and commit them.
---
7. Three.js (optional)
Add a Three.js scene behind the slides for ambient motion. The key rules:
- Own rAF loop — do not integrate with the HF seek timeline. Three.js runs its own
requestAnimationFrameloop independent of playhead position. - One persistent canvas — create the canvas once; update geometry/materials in-place per scene.
- Guard renderer creation — WebGL may be unavailable (software-GL environments, some CI contexts). Create the renderer inside try/catch once; if it fails, hide the canvas and expose no-op stubs. Do not spam
console.error— silence it during creation and restore it infinally. - Full-bleed, behind content — fix the canvas at
z-index: 0,pointer-events: none, behind scene frames atz-index: 1. - Transparent scene frames — set scene backgrounds to
transparentso the 3D canvas shows through. Use a radial-gradient scrim on the text container (not the scene frame itself) to keep type legible while letting 3D show in the margins. - Expose a mood hook — export
window.__threeApplyMood(sceneKey)so the visibility controller can switch particle colors, toggle sub-objects, or change the clear color when the active scene changes.
// In index.html — Three.js setup (module script)
import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js";
var canvas = document.getElementById("three-canvas");
var renderer = null;
var _err = console.error;
console.error = function () {}; // silence THREE's multi-line GPU error during init
try {
renderer = new THREE.WebGLRenderer({ canvas: canvas, alpha: true, antialias: true });
} catch (e) {
// renderer stays null
} finally {
console.error = _err;
}
if (!renderer) {
// Graceful degradation — branded layout is the fallback.
canvas.style.display = "none";
window.__threeApplyMood = function () {};
// Do NOT start the rAF loop.
} else {
renderer.setSize(1920, 1080);
renderer.setPixelRatio(1);
canvas.style.cssText =
"position:fixed;top:0;left:0;width:1920px;height:1080px;z-index:0;pointer-events:none";
var scene = new THREE.Scene();
var camera = new THREE.PerspectiveCamera(60, 1920 / 1080, 0.1, 1000);
camera.position.set(0, 0, 5);
// --- build your particle system, meshes, etc. here ---
// Mood config: map sceneId → visual state (colors, sub-object visibility, bg color)
var MOODS = {
cover: {
/* particle color, opacity, bg ... */
},
problem: {
/* ... */
},
// one entry per scene key
};
window.__threeApplyMood = function (sceneKey) {
var m = MOODS[sceneKey] || MOODS["cover"];
// update geometry attributes, material opacity, sub-group visibility, etc.
};
window.__threeApplyMood("cover"); // apply initial state
// --- own rAF loop ---
var lastTime = null;
function animate(ts) {
requestAnimationFrame(animate);
if (lastTime === null) lastTime = ts;
var dt = Math.min((ts - lastTime) / 1000, 0.05);
lastTime = ts;
// update particles, rotate objects, etc.
renderer.render(scene, camera);
}
requestAnimationFrame(animate);
}CSS for transparent scene frames + scrim:
/* Three.js canvas — always behind everything */
#three-canvas {
position: fixed;
top: 0;
left: 0;
width: 1920px;
height: 1080px;
z-index: 0;
pointer-events: none;
}
/* Scene frames are transparent so the 3D canvas shows through */
.scene-frame {
position: absolute;
top: 0;
left: 0;
width: 1920px;
height: 1080px;
background: transparent; /* NOT opaque — 3D would be occluded */
z-index: 1;
}
/* Scrim on the TEXT container — not the scene frame.
Radial gradient: opaque in the center where text is, transparent at edges
so 3D shows in the whitespace margins. */
.slide-inner.scrim-light {
background: radial-gradient(
ellipse 75% 80% at 50% 50%,
rgba(255, 255, 255, 0.88) 30%,
rgba(255, 255, 255, 0.6) 65%,
rgba(255, 255, 255, 0) 100%
);
}---
8. Foot-gun checklist
| Failure | Symptom | One-line fix |
|---|---|---|
| Island not duplicated in wrapper | Slideshow chrome never renders; no slide counter, no prev/next | Copy the <script type="application/hyperframes-slideshow+json"> block verbatim into the <hyperframes-slideshow> element in demo.html |
| Audio in the iframe | All SFX silent | Move Audio elements and unlock logic to demo.html; post {type:'hf-sfx',name} from index.html |
| No self-clock in composition | All scene frames stacked / wrong slide visible at load | Add the root GSAP timeline (window.__timelines["root"]) and the onUpdate visibility controller as shown in Section 3 |
| Content opacity:0 with no engine | Blank slides — [data-anim] elements invisible at rest | Call updateVisibility(0) synchronously after defining the controller so the first slide is shown immediately |
| Keydown bound to the element without focus | ArrowLeft/Right dead | Add tabindex="0" to <hyperframes-slideshow> so it can receive keyboard focus |
| Opaque scene background occluding Three.js canvas | 3D never visible | Set background: transparent on .scene-frame; put the visual fill on the text scrim container instead |
| WebGL renderer creation spams errors in headless envs | Console noise, rAF loop starts anyway | Silence console.error during new THREE.WebGLRenderer(...), restore in finally, guard the rAF start on renderer !== null |
| Branch scene missing from postMessage manifest | Hotspot navigates but slide is blank | Include every scene — main line and branch — in the scenes array of the postTimeline() message |
| Prominent 3D/content in nav-capsule zone | Bright element bleeds behind/beside the nav pill | Keep the bottom-right ~360×140px region clear; add a background-matching gradient overlay on any slide whose 3D mood is bright in that corner |
Related skills
How it compares
Pick slideshow for HyperFrames JSON slide islands and GSAP-driven decks instead of static slide export pipelines.
FAQ
What makes a HyperFrames slideshow different from a normal composition?
A HyperFrames slideshow adds a JSON island declaring slides and connections atop scenes, clips, and GSAP timelines; the SlideshowController reads that island to provide discrete navigation with fragments, branching, and hotspots.
Which navigation features does the slideshow skill cover?
The slideshow skill covers fragment reveals, branching sequences, hotspot navigation, and SlideshowController-driven slide changes for HyperFrames presentations, pitch decks, and interactive decks.
Is Slideshow safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.