Setting the file. One moment. Concept Demo Decode Pan · Hyperframes Animation · heygen-com/hyperframes · Skills DocsBlueprints Index
(opens in a new tab)
examples/concept-demo-decode-pan.html
HTML·520 lines·18 KB
8 <!--
9 HyperFrames composition.tsx.
10
11 Choreography (4 phases, 7 seconds total):
12 0.0 – 0.7s Shot 1 static text fades in + rises ("HyperFrames renders")
13 0.7 – 1.6s Hacker-flip decodes the accent word ("video")
14 2.8 – 3.5s Horizontal pan to Shot 2 with parallax exit + scale-in
15 3.7 – 6.1s Cursor-tracked typing: "HTML, CSS and JS become MP4"
16 (NOT @the source/layout-utils, NOT a charWidthRatio constant)
17 - Bar width pre-allocated from the full text — never tweened
18 - Camera follows cursor by tweening the strip's `x`, with a piecewise
19 Math.min(initialOffset, trackingOffset) for the two-phase camera
20 - Hacker-flip glyph flicker via deterministic int hash, not Math.random
21 -->
22
23 <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
24
25 <style>
26 :root {
27 --bg: linear-gradient(
28 135deg,
29 #3a3a3a 0%,
30 #17211f 30%,
31 #0b2328 48%,
32 #1f3518 70%,
33 #343434 100%
34 );
35 --text-dark: #f8fafc;
36 --text-highlight: #18d9e8;
37 --text-highlight-2: #7bea5a;
38 --search-bg: rgba(14, 24, 23, 0.82);
39 --cursor: #7bea5a;
40 }
41
42 * {
43 margin: 0;
44 padding: 0;
45 box-sizing: border-box;
46 }
47 html,
48 body {
49 margin: 0;
50 width: 1920px;
51 height: 1080px;
52 overflow: hidden;
53 background: var(--bg);
54 font-family:
55 "Inter",
56 system-ui,
57 -apple-system,
58 sans-serif;
59 color: var(--text-dark);
60 }
61
62 .viewport {
63 position: absolute;
64 inset: 0;
65 overflow: hidden;
66 }
67 .strip {
68 display: flex;
69 height: 100%;
70 will-change: transform;
71 }
72 .shot {
73 width: 1920px;
74 height: 100%;
75 position: relative;
76 flex-shrink: 0;
77 }
78
79 /* ============================================================
80 SHOT 1 — "HyperFrames renders video"
81 ============================================================ */
82 .shot1-content {
83 position: absolute;
84 inset: 0;
85 display: flex;
86 align-items: center;
87 justify-content: center;
88 opacity: 0; /* GSAP fromTo fades in */
89 }
90 .shot1-row {
91 display: flex;
92 align-items: baseline;
93 gap: 0.4em;
94 font-size: 130px;
95 }
96 .shot1-static {
97 font-weight: 500;
98 color: var(--text-dark);
99 text-shadow: 0 0 36px rgba(24, 217, 232, 0.18);
100 }
101 .shot1-accent {
102 font-weight: 700;
103 display: flex;
104 perspective: 800px; /* required for the per-glyph rotateX */
105 }
106 .flip-glyph {
107 position: relative;
108 display: inline-block;
109 min-width: 0;
110 }
111 .flip-glyph.space {
112 min-width: 0.4em;
113 }
114 .flip-glyph .ghost {
115 opacity: 0;
116 }
117 .flip-glyph .anim {
118 position: absolute;
119 left: 0;
120 top: 0;
121 width: 100%;
122 color: var(--text-highlight);
123 background: linear-gradient(135deg, var(--text-highlight) 0%, var(--text-highlight-2) 100%);
124 -webkit-background-clip: text;
125 background-clip: text;
126 -webkit-text-fill-color: transparent;
127 filter: drop-shadow(0 0 24px rgba(24, 217, 232, 0.32))
128 drop-shadow(0 0 36px rgba(123, 234, 90, 0.22));
129 opacity: 0;
130 transform: perspective(600px) rotateX(90deg);
131 transform-origin: bottom;
132 backface-visibility: hidden;
133 }
134
135 /* ============================================================
136 SHOT 2 — Search bar with cursor-tracked typing
137 ============================================================ */
138 .shot2-bar {
139 position: absolute;
140 top: 50%;
141 /* left set by JS once we know the bar's world coordinate */
142 height: 240px;
143 background: var(--search-bg);
144 border: 1px solid rgba(123, 234, 90, 0.22);
145 box-shadow:
146 0 0 60px rgba(24, 217, 232, 0.2),
147 0 0 96px rgba(123, 234, 90, 0.14),
148 inset 0 0 32px rgba(24, 217, 232, 0.08);
149 border-radius: 999px;
150 display: flex;
151 align-items: center;
152 padding-left: 120px;
153 padding-right: 180px;
154 opacity: 0; /* GSAP fromTo fades in */
155 transform-origin: left center;
156 }
157 .search-text {
158 font-size: 120px;
159 font-weight: 400;
160 color: var(--text-dark);
161 text-shadow: 0 0 24px rgba(248, 250, 252, 0.16);
162 white-space: pre;
163 line-height: 1;
164 }
165 .search-cursor {
166 font-size: 120px;
167 color: var(--cursor);
168 text-shadow: 0 0 26px rgba(123, 234, 90, 0.42);
169 margin-left: 4px;
170 font-weight: 300;
171 line-height: 1;
172 }
173
174 /* Hidden probe used by measureNodeWidth */
175 .measure-probe {
176 position: absolute;
177 left: -99999px;
178 top: -99999px;
179 visibility: hidden;
180 white-space: pre;
181 }
182 </style>
183 </head>
184 <body>
185 <div
186 id="root"
187 data-composition-id="main"
188 data-start="0"
189 data-duration="7"
190 data-width="1920"
191 data-height="1080"
192 >
193 <div class="viewport" data-layout-allow-overflow>
194 <div class="strip" data-layout-allow-overflow>
195 <!-- =====================================================
196 SHOT 1 — "HyperFrames renders video"
197 ===================================================== -->
198 <div
199 id="shot-decode"
200 class="shot shot1 clip"
201 data-start="0"
202 data-duration="3.5"
203 data-track-index="1"
204 >
205 <div class="shot1-content">
206 <div class="shot1-row">
207 <span class="shot1-static">HyperFrames renders</span>
208 <span class="shot1-accent" aria-label="video">
209 <!-- .flip-glyph spans generated by JS -->
210 </span>
211 </div>
212 </div>
213 </div>
214
215 <!-- =====================================================
216 SHOT 2 — Search bar typing
217 ===================================================== -->
218 <div
219 id="shot-typing"
220 class="shot shot2 clip"
221 data-start="2.5"
222 data-duration="4.5"
223 data-track-index="2"
224 >
225 <div class="shot2-bar">
226 <span class="search-text"></span><span class="search-cursor">_</span>
227 </div>
228 </div>
229 </div>
230 </div>
231 </div>
232
233 <script>
234 /* ================================================================
235 CONSTANTS — all baked at setup time.
236 ================================================================ */
237 const W = 1920,
238 H = 1080;
239 const COMPOSITION_DURATION = 7;
240 const FPS_HASH = 60; // synthetic clock for the flicker hash
241
242 const TIMING = {
243 // Phase 1: shot1 text fades in + rises
244 shot1EntryStart: 0.0,
245 shot1EntryDur: 0.67,
246
247 // Phase 2: hacker-flip "video"
248 flipStart: 0.7,
249 flipStagger: 0.066, // ~2 frames at 30fps per glyph
250 flipDuration: 0.55,
251
252 // Phase 3: horizontal pan
253 panStart: 2.83,
254 panDuration: 0.67,
255
256 // Phase 4: cursor-tracked typing
257 typingStart: 3.67, // panStart + panDuration + 0.17 buffer
258 charRate: 0.083, // seconds per character (~2.5 frames at 30fps)
259 };
260
261 const FULL_TEXT = "HTML, CSS and JS become MP4";
262 const FONT_SIZE = 120;
263 const PADDING_LEFT = 120;
264 const PADDING_RIGHT = 180;
265 const CURSOR_VIS_W = FONT_SIZE * 0.6; // visual cursor width approximation
266 const CURSOR_TARGET = W * 0.7; // screen X where cursor locks
267 const BAR_LEFT_MARGIN = 80; // initial left margin in Phase 4
268 const PARALLAX_DIST = 400; // px Shot 1 moves extra during pan
269
270 /* ================================================================
271 BUILD DOM — flip glyphs (Shot 1) generated synchronously.
272 The accent text is "video" → 5 glyphs.
273 ================================================================ */
274 const accentEl = document.querySelector(".shot1-accent");
275 const ACCENT_WORD = "video";
276 ACCENT_WORD.split("").forEach((char, index) => {
277 const span = document.createElement("span");
278 span.className = "flip-glyph" + (char === " " ? " space" : "");
279 span.dataset.char = char;
280 span.dataset.index = String(index);
281 // Each glyph needs its own font-size to match the surrounding row.
282 span.style.fontSize = "130px";
283 const ghost = document.createElement("span");
284 ghost.className = "ghost";
285 ghost.textContent = char === " " ? " " : char;
286 const anim = document.createElement("span");
287 anim.className = "anim";
288 anim.textContent = char === " " ? " " : char;
289 span.append(ghost, anim);
290 accentEl.appendChild(span);
291 });
292
293 /* ================================================================
294 TEXT MEASUREMENT — uses a hidden DOM probe so we capture
295 real letter-spacing, kerning, and font-feature widths.
296 Must run AFTER document.fonts.ready.
297 ================================================================ */
298 function measureNodeWidth(text, font) {
299 const probe = document.createElement("span");
300 probe.className = "measure-probe";
301 probe.style.font = font;
302 probe.style.whiteSpace = "pre";
303 probe.textContent = text;
304 document.body.appendChild(probe);
305 const width = probe.getBoundingClientRect().width;
306 probe.remove();
307 return width;
308 }
309
310 /* ================================================================
311 TIMELINE BUILD — fires after fonts are ready so measurements
312 use the real rendered metrics, not fallback fonts.
313 ================================================================ */
314 window.__timelines = window.__timelines || {};
315 const tl = gsap.timeline({ paused: true });
316
317 // Register early so HF can find it; tweens are added below.
318 window.__timelines["main"] = tl;
319
320 document.fonts.ready.then(() => {
321 const searchFont = `400 ${FONT_SIZE}px Inter, system-ui, sans-serif`;
322 const fullTextWidth = measureNodeWidth(FULL_TEXT, searchFont);
323 const barWidth = PADDING_LEFT + fullTextWidth + CURSOR_VIS_W + PADDING_RIGHT;
324
325 // Pre-allocate the bar's final width — no width tweens ever.
326 const barEl = document.querySelector(".shot2-bar");
327 barEl.style.width = barWidth + "px";
328
329 // Initial bar position in Shot 2's local space.
330 // We position it at LEFT_MARGIN from the shot's left edge.
331 barEl.style.left = BAR_LEFT_MARGIN + "px";
332 barEl.style.transform = "translateY(-50%)"; // vertical center
333
334 // ============================================================
335 // PHASE 1: Shot 1 text fade in + rise
336 // ============================================================
337 tl.fromTo(
338 ".shot1-content",
339 { opacity: 0, y: 30 },
340 {
341 opacity: 1,
342 y: 0,
343 duration: TIMING.shot1EntryDur,
344 ease: "power2.out",
345 },
346 TIMING.shot1EntryStart,
347 );
348
349 // ============================================================
350 // PHASE 2: Hacker-flip "video"
351 // ============================================================
352 const CHAR_POOL = "abcdefghijklmnopqrstuvwxyz";
353 const FLICKER = 3;
354 const REVEAL_AT = 0.6;
355
356 // Deterministic 32-bit mix — replaces Math.random / seeded RNG.
357 function pseudoHash(i, t) {
358 return ((i * 374761393 + t * 668265263) >>> 0) % CHAR_POOL.length;
359 }
360
361 document.querySelectorAll(".flip-glyph").forEach((glyph) => {
362 const index = Number(glyph.dataset.index);
363 const real = glyph.dataset.char === " " ? " " : glyph.dataset.char;
364 const anim = glyph.querySelector(".anim");
365 const start = TIMING.flipStart + index * TIMING.flipStagger;
366
367 tl.fromTo(
368 anim,
369 { rotationX: 90, opacity: 0, "--p": 0 },
370 {
371 rotationX: 0,
372 opacity: 1,
373 "--p": 1,
374 duration: TIMING.flipDuration,
375 ease: "back.out(1.6)", // spring(stiffness:150, damping:14)
376 onUpdate: function () {
377 const p = Number(gsap.getProperty(anim, "--p"));
378 if (p >= REVEAL_AT) {
379 if (anim.textContent !== real) anim.textContent = real;
380 } else {
381 const localFrame = Math.floor((tl.time() - start) * FPS_HASH);
382 const bucket = Math.max(0, Math.floor(localFrame / FLICKER));
383 anim.textContent = CHAR_POOL[pseudoHash(index, bucket)];
384 }
385 },
386 },
387 start,
388 );
389 });
390
391 // ============================================================
392 // PHASE 3: Horizontal pan + parallax exit + Shot 2 entry
393 // All three tweens at the same timeline position run in parallel.
394 // ============================================================
395 // (a) Camera pan — strip slides one full viewport left.
396 tl.to(
397 ".strip",
398 {
399 x: -W,
400 duration: TIMING.panDuration,
401 ease: "power3.inOut", // cinematic slow-in-slow-out
402 },
403 TIMING.panStart,
404 );
405
406 // (b) Shot 1 parallax exit — content moves EXTRA -PARALLAX_DIST.
407 tl.to(
408 ".shot1-content",
409 {
410 x: -PARALLAX_DIST,
411 duration: TIMING.panDuration,
412 ease: "power3.inOut",
413 },
414 TIMING.panStart,
415 );
416
417 // Shot 1 fades out partway through the pan so the eye lands on Shot 2.
418 tl.to(
419 ".shot1-content",
420 {
421 opacity: 0,
422 duration: TIMING.panDuration * 0.4,
423 ease: "power2.out",
424 },
425 TIMING.panStart,
426 );
427
428 // (c) Shot 2 bar entry — fade + scale with mild overshoot ("landing").
429 tl.fromTo(
430 ".shot2-bar",
431 { opacity: 0, scale: 0.8 },
432 {
433 opacity: 1,
434 scale: 1,
435 duration: TIMING.panDuration,
436 ease: "back.out(1.2)",
437 },
438 TIMING.panStart,
439 );
440
441 // ============================================================
442 // PHASE 4: Cursor-tracked typing.
443 //
444 // The strip is now at x = -W (Shot 2 fully on screen). For the
445 // cursor-track effect we'll *further* shift the strip by a piecewise
446 // amount: hold initial offset while the empty bar's cursor is left
447 // of CURSOR_TARGET, then follow once typing pushes it past.
448 // ============================================================
449 const searchTextEl = document.querySelector(".search-text");
450 const stripEl = document.querySelector(".strip");
451
452 const CURSOR_WIDTH_HALF = CURSOR_VIS_W / 2;
453 const STRIP_BASE_X = -W; // strip's x at the end of Phase 3
454
455 // Initial Phase-4 offset = 0 (no additional shift on top of -W).
456 // i.e. bar already sits at BAR_LEFT_MARGIN inside Shot 2.
457 const INITIAL_OFFSET = 0;
458
459 // Typing driver — a clock that runs from 0 to FULL_TEXT.length.
460 const typingProxy = { progress: 0 };
461 const typingDur = FULL_TEXT.length * TIMING.charRate;
462 const typingEnd = TIMING.typingStart + typingDur;
463 const postTypingHold = Math.max(0, COMPOSITION_DURATION - typingEnd);
464
465 tl.to(
466 typingProxy,
467 {
468 progress: FULL_TEXT.length,
469 duration: typingDur,
470 ease: "none",
471 onUpdate: function () {
472 const charsTyped = Math.min(FULL_TEXT.length, Math.floor(typingProxy.progress));
473 const visibleText = FULL_TEXT.slice(0, charsTyped);
474 if (searchTextEl.textContent !== visibleText) {
475 searchTextEl.textContent = visibleText;
476 }
477
478 // Measure current visible width to compute cursor screen position.
479 // Cheap because Inter's metrics are cached after the first call.
480 const visibleW =
481 visibleText.length === 0 ? 0 : measureNodeWidth(visibleText, searchFont);
482
483 // Cursor X in *Shot 2's* coordinate system:
484 // shot left edge → bar left edge (BAR_LEFT_MARGIN)
485 // → cursor within bar (PADDING_LEFT + visibleW + CURSOR_WIDTH_HALF)
486 const cursorXInShot2 = BAR_LEFT_MARGIN + PADDING_LEFT + visibleW + CURSOR_WIDTH_HALF;
487
488 // For the cursor to land at CURSOR_TARGET on the *screen*, the
489 // strip must be shifted by:
490 // stripX = CURSOR_TARGET - cursorXInShot2 - W (the -W accounts
491 // for Shot 2 being the SECOND shot in the strip)
492 const trackingStripX = CURSOR_TARGET - cursorXInShot2 - W;
493
494 // Piecewise: hold STRIP_BASE_X + INITIAL_OFFSET until tracking
495 // would pan FURTHER LEFT than the base, then follow.
496 const finalStripX = Math.min(STRIP_BASE_X + INITIAL_OFFSET, trackingStripX);
497
498 gsap.set(stripEl, { x: finalStripX });
499 },
500 },
501 TIMING.typingStart,
502 );
503
504 if (postTypingHold > 0) {
505 tl.to(
506 ".search-cursor",
507 {
508 opacity: 0.22,
509 duration: postTypingHold / 2,
510 repeat: 1,
511 yoyo: true,
512 ease: "none",
513 },
514 typingEnd,
515 );
516 }
517 });
518 </script>
519 </body>
520</html>