Setting the file. One moment. Metric Video Text Pivot · Hyperframes Animation · heygen-com/hyperframes · Skills DocsBlueprints Index
(opens in a new tab)
examples/metric-video-text-pivot.html
HTML·779 lines·25 KB
8 <!--
9 HyperFrames composition.tsx.
10
11 Choreography (4 phases, 6.5 seconds total):
12 0.17 – 0.87s "HyperFrames" badge fades in (top)
13 0.17 – 0.87s Demo screen card enters centered (scale 0.6 → 1)
14 2.20 – 3.00s Video slides left to 32% W (scale → 0.85); MP4 pops in on right
15 with 5-layer green depth stack and bouncy back.out(1.6)
16 3.00 – 3.86s MP4 breathes (±2% scale at ~1.9 Hz)
17 3.86 – 4.46s Video and MP4 slide off left + fade; typing stage fades in center
18 4.36 – 5.13s "HTML pages become video" types char-by-char (23 chars at 30 ch/s)
19 Accent words "pages" and "video" in brand green
20 4.83 – 5.43s Gradient pill (purple → green) scales in behind line 2 with glow halo
21 5.13 – 5.63s "frame by frame." types char-by-char inside the pill
22 0 – 6.50s Continuous: video float, cursor blink, stat breath
23 (each gated by time window)
24 so entry, continuous motion, and static tilt never overwrite each other
25 - Conditional rendering ({frame > X && ...}) replaced by permanent DOM +
26 opacity 0 + gates inside the scene-ticker onUpdate
27 - Cursor blink derived from tl.time() via Math.floor(t * 2) % 2
28 - Demo screen is a self-contained CSS scene (.video-scene); to showcase real
29 footage, drop a local mp4 back in as a muted <video class="clip"> (see
30 the note at the .video-content markup)
31 - 3D depth layers built once at composition setup with document.createElement
32 -->
33
34 <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
35
36 <style>
37 :root {
38 --bg-dark: #0a0a18;
39 --text-primary: #ffffff;
40 --text-secondary: rgba(255, 255, 255, 0.65);
41 --brand-purple: #7c3aed;
42 --brand-purple-soft: #a78bfa;
43 --brand-green: #22c55e;
44 --brand-purple-glow: rgba(124, 58, 237, 1);
45 --brand-green-glow: rgba(34, 197, 94, 1);
46 }
47 * {
48 margin: 0;
49 padding: 0;
50 box-sizing: border-box;
51 }
52 html,
53 body {
54 margin: 0;
55 width: 1920px;
56 height: 1080px;
57 overflow: hidden;
58 background: var(--bg-dark);
59 font-family:
60 "Inter",
61 system-ui,
62 -apple-system,
63 sans-serif;
64 color: var(--text-primary);
65 }
66
67 .stage {
68 position: absolute;
69 inset: 0;
70 overflow: hidden;
71 }
72
73 /* ============================================================
74 BACKGROUND + AMBIENT GLOW
75 ============================================================ */
76 .bg {
77 position: absolute;
78 inset: 0;
79 background:
80 radial-gradient(ellipse at 50% 50%, rgba(40, 30, 80, 0.5), transparent 65%),
81 linear-gradient(135deg, #0a0a18 0%, #160a1f 100%);
82 }
83 .ambient-glow {
84 position: absolute;
85 left: 50%;
86 top: 50%;
87 transform: translate(-50%, -50%);
88 width: 1536px;
89 height: 864px;
90 background: radial-gradient(
91 ellipse at center,
92 rgba(147, 51, 234, 0.13) 0%,
93 rgba(34, 197, 94, 0.08) 30%,
94 transparent 70%
95 );
96 filter: blur(80px);
97 pointer-events: none;
98 }
99
100 /* ============================================================
101 BADGE
102 ============================================================ */
103 .badge {
104 position: absolute;
105 top: 42px;
106 left: 50%;
107 transform: translateX(-50%);
108 padding: 12px 32px;
109 font-size: 112px;
110 font-weight: 800;
111 color: var(--text-primary);
112 line-height: 1;
113 white-space: nowrap;
114 text-shadow:
115 0 0 32px rgba(248, 250, 252, 0.12),
116 0 0 72px rgba(24, 217, 232, 0.1);
117 will-change: transform, opacity;
118 /* initial opacity 0 + scale 0.9 via gsap.set */
119 }
120 .badge .accent {
121 color: var(--brand-purple-soft);
122 background: linear-gradient(135deg, var(--brand-purple-soft) 0%, var(--brand-purple) 72%);
123 -webkit-background-clip: text;
124 background-clip: text;
125 -webkit-text-fill-color: transparent;
126 filter: drop-shadow(0 0 28px rgba(124, 58, 237, 0.34));
127 }
128
129 /* ============================================================
130 VIDEO CARD — three nested wrappers
131 ============================================================ */
132 .video-pos {
133 position: absolute;
134 left: 0;
135 top: 0;
136 width: 1040px;
137 height: 585px; /* 16:9 ratio, intentionally large for the pivot beat */
138 margin-left: -520px; /* so x: W/2 centers it */
139 margin-top: -292.5px;
140 will-change: transform, opacity;
141 }
142 .video-float {
143 width: 100%;
144 height: 100%;
145 will-change: transform;
146 }
147 .video-tilt {
148 width: 100%;
149 height: 100%;
150 transform-style: preserve-3d;
151 transform: rotateX(5deg) rotateY(15deg);
152 }
153 .video-content {
154 width: 100%;
155 height: 100%;
156 border-radius: 16px;
157 overflow: hidden;
158 border: 2px solid rgba(255, 255, 255, 0.1);
159 box-shadow:
160 0 30px 60px rgba(0, 0, 0, 0.5),
161 0 0 80px rgba(147, 51, 234, 0.2),
162 0 0 120px rgba(34, 197, 94, 0.15);
163 position: relative;
164 }
165 .video-scene {
166 position: absolute;
167 inset: 0;
168 background:
169 radial-gradient(circle at 30% 30%, rgba(255, 200, 100, 0.4), transparent 50%),
170 linear-gradient(135deg, #1a3a5f 0%, #2a4570 50%, #3a2a5a 100%);
171 }
172 .video-scene::before {
173 content: "";
174 position: absolute;
175 top: 35%;
176 left: 40%;
177 width: 220px;
178 height: 220px;
179 border-radius: 50%;
180 background: linear-gradient(135deg, #fbbf24, #f97316);
181 opacity: 0.6;
182 filter: blur(8px);
183 }
184 .video-caption {
185 position: absolute;
186 bottom: 60px;
187 left: 50%;
188 transform: translateX(-50%);
189 padding: 12px 24px;
190 background: rgba(0, 0, 0, 0.7);
191 border-radius: 8px;
192 color: var(--text-primary);
193 font-size: 34px;
194 font-weight: 600;
195 white-space: nowrap;
196 }
197 .video-caption .accent {
198 color: var(--brand-green);
199 }
200 .video-reflection {
201 position: absolute;
202 top: 0;
203 left: 0;
204 right: 0;
205 height: 40%;
206 background: linear-gradient(180deg, rgba(255, 255, 255, 0.08) 0%, transparent 100%);
207 pointer-events: none;
208 }
209
210 /* ============================================================
211 MP4 STAT — three nested wrappers + depth stack
212 ============================================================ */
213 .stat-pos {
214 position: absolute;
215 left: 0;
216 top: 0;
217 will-change: transform, opacity;
218 }
219 .stat-breath {
220 will-change: transform;
221 }
222 .stat-tilt {
223 transform-style: preserve-3d;
224 transform: rotateX(5deg) rotateY(-15deg);
225 }
226 .depth-stack {
227 position: relative;
228 display: inline-block;
229 font-size: 340px;
230 font-weight: 900;
231 letter-spacing: -0.03em;
232 line-height: 1;
233 }
234 .depth-stack .depth-layer.front {
235 position: relative;
236 color: var(--brand-green);
237 }
238 .depth-stack .depth-layer.back {
239 position: absolute;
240 top: var(--top);
241 left: var(--left);
242 color: rgba(34, 197, 94, var(--alpha));
243 }
244
245 /* ============================================================
246 TYPING STAGE — center, fades in during pivot
247 ============================================================ */
248 .typing-stage {
249 position: absolute;
250 left: 50%;
251 top: 50%;
252 transform: translate(-50%, -50%);
253 will-change: transform, opacity;
254 }
255 .typing-tilt {
256 perspective: 1200px;
257 transform-style: preserve-3d;
258 transform: rotateY(-15deg) rotateX(5deg);
259 display: flex;
260 flex-direction: column;
261 align-items: center;
262 justify-content: center;
263 gap: 30px;
264 }
265 .line1 {
266 display: flex;
267 align-items: center;
268 white-space: pre;
269 }
270 .seg {
271 font-size: 124px;
272 font-weight: 700;
273 color: var(--text-primary);
274 white-space: pre;
275 line-height: 1;
276 }
277 .seg.accent,
278 .seg.accent2 {
279 color: var(--brand-green);
280 }
281
282 .cursor {
283 display: inline-block;
284 width: 10px;
285 height: 124px;
286 background-color: var(--text-primary);
287 margin-left: 8px;
288 vertical-align: middle;
289 transform: translateY(4px);
290 opacity: 0;
291 will-change: opacity;
292 }
293 .cursor.green {
294 background-color: var(--brand-green);
295 }
296
297 .line2-wrap {
298 position: relative;
299 display: flex;
300 align-items: center;
301 justify-content: center;
302 padding: 24px 64px;
303 }
304 .pill-bg {
305 position: absolute;
306 top: 50%;
307 left: 50%;
308 transform: translate(-50%, -50%) scale(0);
309 width: calc(100% + 80px);
310 height: calc(100% + 40px);
311 border-radius: 80px;
312 background: linear-gradient(90deg, var(--brand-purple) 0%, var(--brand-green) 100%);
313 opacity: 0;
314 z-index: -1;
315 will-change: transform, opacity;
316 }
317 .pill-glow {
318 position: absolute;
319 top: 50%;
320 left: 50%;
321 transform: translate(-50%, -50%);
322 width: calc(100% + 100px);
323 height: calc(100% + 60px);
324 border-radius: 80px;
325 background: radial-gradient(
326 ellipse at center,
327 var(--brand-purple-glow) 0%,
328 transparent 70%
329 );
330 opacity: 0;
331 filter: blur(30px);
332 z-index: -2;
333 will-change: opacity;
334 }
335 .line2-content {
336 display: flex;
337 align-items: center;
338 position: relative;
339 z-index: 1;
340 opacity: 0;
341 will-change: opacity;
342 }
343 .seg.line2 {
344 color: var(--text-primary);
345 }
346
347 /* ============================================================
348 VIGNETTE
349 ============================================================ */
350 .vignette {
351 position: absolute;
352 inset: 0;
353 background: radial-gradient(ellipse at center, transparent 50%, rgba(0, 0, 0, 0.5) 100%);
354 pointer-events: none;
355 z-index: 400;
356 }
357 </style>
358 </head>
359 <body>
360 <div
361 id="root"
362 data-composition-id="main"
363 data-start="0"
364 data-duration="6.5"
365 data-width="1920"
366 data-height="1080"
367 >
368 <div
369 class="stage clip"
370 data-start="0"
371 data-duration="6.5"
372 data-track-index="1"
373 id="scene-stage"
374 >
375 <div class="bg"></div>
376 <div class="ambient-glow"></div>
377
378 <div class="badge" id="badge">Hyper<span class="accent">Frames</span></div>
379
380 <!-- Video card -->
381 <div class="video-pos" id="video-pos" data-layout-allow-overflow>
382 <div class="video-float" id="video-float">
383 <div class="video-tilt">
384 <div class="video-content">
385 <div class="video-scene"></div>
386 <!-- To showcase real footage, add a local mp4 on top of .video-scene:
387 <video id="demo-video" class="clip" src="assets/demo.mp4"
388 style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover"
389 data-start="0.17" data-duration="3.69" data-track-index="2"
390 data-media-start="0" muted playsinline></video> -->
391 <div class="video-caption">
392 HTML, CSS and JS become <span class="accent">video</span>
393 </div>
394 <div class="video-reflection"></div>
395 </div>
396 </div>
397 </div>
398 </div>
399
400 <!-- MP4 stat -->
401 <div class="stat-pos" id="stat-pos" data-layout-allow-overflow>
402 <div class="stat-breath" id="stat-breath">
403 <div class="stat-tilt">
404 <div class="depth-stack" id="depth-stack-97" data-text="MP4"></div>
405 </div>
406 </div>
407 </div>
408
409 <!-- Typing -->
410 <div class="typing-stage" id="typing-stage">
411 <div class="typing-tilt">
412 <div class="line1">
413 <span class="seg main">HTML </span><span class="seg accent">pages</span
414 ><span class="seg suffix"> become </span><span class="seg accent2">video</span
415 ><span class="cursor cursor1" id="cursor1"></span>
416 </div>
417 <div class="line2-wrap">
418 <div class="pill-bg" id="pill-bg"></div>
419 <div class="pill-glow" id="pill-glow"></div>
420 <div class="line2-content">
421 <span class="seg line2">frame by frame.</span
422 ><span class="cursor cursor2" id="cursor2"></span>
423 </div>
424 </div>
425 </div>
426 </div>
427 </div>
428
429 <div class="vignette"></div>
430 </div>
431
432 <script>
433 /* ================================================================
434 CONSTANTS
435 ================================================================ */
436 const W = 1920,
437 H = 1080;
438 const TOTAL_DUR = 6.5;
439
440 /* ASR-anchored timing (the source source frame counts converted to seconds at 30fps). */
441 const TIMING = {
442 // Phase 1: video + badge enter
443 badgeAt: 0.17,
444 badgeDur: 0.7,
445 videoEntryAt: 0.17,
446 videoEntryDur: 0.7,
447
448 // Phase 2: video slides, MP4 appears
449 slideAt: 2.2,
450 videoSlideDur: 0.8,
451 videoSlideX: W * 0.29,
452 videoSlideScale: 0.92,
453
454 statEntryDur: 0.6,
455 statEntryX: W * 0.6,
456 statBreathAt: 3.0, // entry settles by here, breath begins
457 statBreathDur: 0.86, // until exit at 3.86
458
459 // Phase 3: pivot — both exit, typing begins
460 pivotAt: 3.86,
461 exitDur: 0.6,
462 videoExitX: -W * 0.5,
463 statExitX: -W * 0.7,
464 typingStageDur: 0.5,
465
466 // Phase 3 typing
467 typingStartAt: 4.36, // pivotAt + 0.5 s stage settle
468 typeRate: 30, // chars/sec
469
470 // Phase 4: pill
471 pillAt: 4.83, // typing reaches line2 at this time
472 pillDur: 0.6,
473 pillGlowDur: 0.8,
474 };
475
476 /* Text segments and boundaries */
477 const SEG = {
478 main: "HTML ",
479 accent: "pages",
480 suffix: " become ",
481 accent2: "video",
482 line2: "frame by frame.",
483 };
484 const BOUNDS = {
485 mainEnd: SEG.main.length, // 5
486 accentEnd: SEG.main.length + SEG.accent.length, // 10
487 suffixEnd: SEG.main.length + SEG.accent.length + SEG.suffix.length, // 18
488 accent2End: SEG.main.length + SEG.accent.length + SEG.suffix.length + SEG.accent2.length, // 23
489 line2End:
490 SEG.main.length +
491 SEG.accent.length +
492 SEG.suffix.length +
493 SEG.accent2.length +
494 SEG.line2.length, // 38
495 };
496 const TYPING_DUR = (BOUNDS.line2End - 0) / TIMING.typeRate; // ≈ 1.3 s
497
498 /* ================================================================
499 BUILD DOM — 5-layer depth stack for "MP4"
500 ================================================================ */
501 const LAYER_COUNT = 5;
502 const OFFSET_X = 1;
503 const OFFSET_Y = 2;
504 const depthStack = document.getElementById("depth-stack-97");
505 const depthText = depthStack.dataset.text;
506 for (let i = 0; i < LAYER_COUNT; i++) {
507 const layer = document.createElement("div");
508 layer.className = "depth-layer " + (i === LAYER_COUNT - 1 ? "front" : "back");
509 layer.textContent = depthText;
510 if (i < LAYER_COUNT - 1) {
511 layer.setAttribute("aria-hidden", "true");
512 const alpha = 0.62 + 0.08 * (LAYER_COUNT - 2 - i);
513 layer.style.setProperty("--alpha", alpha);
514 layer.style.setProperty("--top", i * OFFSET_Y + "px");
515 layer.style.setProperty("--left", -i * OFFSET_X + "px");
516 }
517 depthStack.appendChild(layer);
518 }
519
520 /* ================================================================
521 INITIAL STATES (via gsap.set, before the timeline runs)
522 ================================================================ */
523 gsap.set("#badge", { opacity: 0, scale: 0.9 });
524
525 // Video starts centered (x = W/2, y = H/2 via the .video-pos margin trick)
526 gsap.set("#video-pos", { x: W / 2, y: H / 2, scale: 0.6, opacity: 0 });
527
528 // Stat starts off-right + below + small + invisible
529 gsap.set("#stat-pos", { x: TIMING.statEntryX, y: H * 0.42 + 60, scale: 0.3, opacity: 0 });
530
531 gsap.set("#typing-stage", { scale: 0.9, opacity: 0 });
532
533 /* ================================================================
534 TIMELINE
535 ================================================================ */
536 window.__timelines = window.__timelines || {};
537 const tl = gsap.timeline({ paused: true });
538 window.__timelines["main"] = tl;
539
540 /* ----------------------------------------------------------------
541 PHASE 1: Badge + Video entry
542 ---------------------------------------------------------------- */
543 tl.to(
544 "#badge",
545 {
546 opacity: 1,
547 scale: 1,
548 duration: TIMING.badgeDur,
549 ease: "power3.out",
550 },
551 TIMING.badgeAt,
552 );
553
554 tl.to(
555 "#video-pos",
556 {
557 scale: 1,
558 opacity: 1,
559 duration: TIMING.videoEntryDur,
560 ease: "power3.out", // spring(stiffness:80, damping:15)
561 },
562 TIMING.videoEntryAt,
563 );
564
565 /* ----------------------------------------------------------------
566 PHASE 2: Video slides left, MP4 appears on right
567 ---------------------------------------------------------------- */
568 tl.to(
569 "#video-pos",
570 {
571 x: TIMING.videoSlideX,
572 scale: TIMING.videoSlideScale,
573 duration: TIMING.videoSlideDur,
574 ease: "power3.out", // spring(stiffness:100, damping:18)
575 },
576 TIMING.slideAt,
577 );
578
579 tl.to(
580 "#stat-pos",
581 {
582 y: H * 0.42, // rises to final y
583 scale: 1,
584 opacity: 1,
585 duration: TIMING.statEntryDur,
586 ease: "back.out(1.6)", // spring(stiffness:150, damping:12)
587 },
588 TIMING.slideAt,
589 );
590
591 /* ----------------------------------------------------------------
592 PHASE 3: Pivot — video + stat exit; typing fades in
593 ---------------------------------------------------------------- */
594 tl.to(
595 "#video-pos",
596 {
597 x: TIMING.videoExitX,
598 scale: 0.8,
599 opacity: 0,
600 duration: TIMING.exitDur,
601 ease: "power3.out", // spring(stiffness:120, damping:20)
602 },
603 TIMING.pivotAt,
604 );
605
606 tl.to(
607 "#stat-pos",
608 {
609 x: TIMING.statExitX,
610 scale: 0.8,
611 opacity: 0,
612 duration: TIMING.exitDur,
613 ease: "power3.out",
614 },
615 TIMING.pivotAt,
616 );
617
618 tl.to(
619 "#typing-stage",
620 {
621 scale: 1,
622 opacity: 1,
623 duration: TIMING.typingStageDur,
624 ease: "power3.out", // spring(stiffness:100, damping:15)
625 },
626 TIMING.pivotAt,
627 );
628
629 /* ----------------------------------------------------------------
630 PHASE 3 (continued): Character-by-character typing
631 ---------------------------------------------------------------- */
632 const segMain = document.querySelector(".seg.main");
633 const segAccent = document.querySelector(".seg.accent");
634 const segSuffix = document.querySelector(".seg.suffix");
635 const segAccent2 = document.querySelector(".seg.accent2");
636 const segLine2 = document.querySelector(".seg.line2");
637
638 const typeProxy = { idx: 0 };
639 function typedIndexAt(t) {
640 if (t < TIMING.typingStartAt) return 0;
641 return Math.min(BOUNDS.line2End, Math.floor((t - TIMING.typingStartAt) * TIMING.typeRate));
642 }
643
644 function renderTypedText(i) {
645 const m = SEG.main.slice(0, Math.min(i, BOUNDS.mainEnd));
646 const a = SEG.accent.slice(0, Math.max(0, Math.min(i - BOUNDS.mainEnd, SEG.accent.length)));
647 const s = SEG.suffix.slice(
648 0,
649 Math.max(0, Math.min(i - BOUNDS.accentEnd, SEG.suffix.length)),
650 );
651 const a2 = SEG.accent2.slice(
652 0,
653 Math.max(0, Math.min(i - BOUNDS.suffixEnd, SEG.accent2.length)),
654 );
655 const l2 = SEG.line2.slice(
656 0,
657 Math.max(0, Math.min(i - BOUNDS.accent2End, SEG.line2.length)),
658 );
659 if (segMain.textContent !== m) segMain.textContent = m;
660 if (segAccent.textContent !== a) segAccent.textContent = a;
661 if (segSuffix.textContent !== s) segSuffix.textContent = s;
662 if (segAccent2.textContent !== a2) segAccent2.textContent = a2;
663 if (segLine2.textContent !== l2) segLine2.textContent = l2;
664 }
665
666 tl.to(
667 typeProxy,
668 {
669 idx: BOUNDS.line2End,
670 duration: TYPING_DUR,
671 ease: "none",
672 onUpdate: function () {
673 renderTypedText(Math.floor(typeProxy.idx));
674 },
675 },
676 TIMING.typingStartAt,
677 );
678
679 /* ----------------------------------------------------------------
680 PHASE 4: Gradient pill behind line 2
681 ---------------------------------------------------------------- */
682 gsap.set("#pill-bg", { scaleX: 0, scaleY: 0.5, opacity: 0 });
683 gsap.set("#pill-glow", { opacity: 0 });
684
685 tl.to(
686 "#pill-bg",
687 {
688 scaleX: 1,
689 scaleY: 1,
690 opacity: 0.9,
691 duration: TIMING.pillDur,
692 ease: "power3.out", // spring(stiffness:80, damping:15)
693 },
694 TIMING.pillAt,
695 );
696
697 tl.to(
698 "#pill-glow",
699 {
700 opacity: 0.5,
701 duration: TIMING.pillGlowDur,
702 ease: "power2.out",
703 },
704 TIMING.pillAt,
705 );
706
707 tl.to(
708 ".line2-content",
709 {
710 opacity: 1,
711 duration: 0.18,
712 ease: "power2.out",
713 },
714 TIMING.pillAt + 0.3,
715 );
716
717 /* ----------------------------------------------------------------
718 CONTINUOUS SCENE-TICKER — float, breath, cursor blink
719 All gated by time windows so they only fire when visible.
720 ---------------------------------------------------------------- */
721 const videoFloat = document.getElementById("video-float");
722 const statBreathEl = document.getElementById("stat-breath");
723 const cursor1El = document.getElementById("cursor1");
724 const cursor2El = document.getElementById("cursor2");
725
726 tl.to(
727 { tick: 0 },
728 {
729 tick: 1,
730 duration: TOTAL_DUR,
731 ease: "none",
732 onUpdate: function () {
733 const t = tl.time();
734 const idx = typedIndexAt(t);
735 renderTypedText(idx);
736
737 // Video float — only while video is visible (Phase 1 + 2)
738 if (t < TIMING.pivotAt) {
739 const floatY = Math.sin(t * 0.9) * 6;
740 gsap.set(videoFloat, { y: floatY });
741 }
742
743 // Stat breath — only while MP4 is settled and not yet exiting
744 if (t > TIMING.statBreathAt && t < TIMING.pivotAt) {
745 const breath = 1 + Math.sin((t - TIMING.statBreathAt) * 1.2) * 0.02;
746 gsap.set(statBreathEl, { scale: breath });
747 }
748
749 // Cursor blink — 1 Hz (Math.floor(t * 2) % 2 === 0 → on for 0.5s, off for 0.5s)
750 const blink = Math.floor(t * 2) % 2 === 0 ? 1 : 0;
751
752 // Cursor 1: visible while typing line 1 (idx < accent2End)
753 // Determine cursor color: green when typing accent segments, white otherwise.
754 if (t >= TIMING.typingStartAt && idx < BOUNDS.accent2End) {
755 const inAccent = idx >= BOUNDS.mainEnd && idx < BOUNDS.accentEnd;
756 const inAccent2 = idx >= BOUNDS.suffixEnd && idx < BOUNDS.accent2End;
757 const isGreen = inAccent || inAccent2;
758 cursor1El.style.opacity = blink;
759 if (cursor1El._lastGreen !== isGreen) {
760 cursor1El.classList.toggle("green", isGreen);
761 cursor1El._lastGreen = isGreen;
762 }
763 } else {
764 cursor1El.style.opacity = 0;
765 }
766
767 // Cursor 2: visible while typing line 2 (idx ≥ accent2End && idx < line2End)
768 if (idx >= BOUNDS.accent2End && idx < BOUNDS.line2End) {
769 cursor2El.style.opacity = blink;
770 } else {
771 cursor2El.style.opacity = 0;
772 }
773 },
774 },
775 0,
776 );
777 </script>
778 </body>
779</html>