chore: import upstream snapshot with attribution
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
---
|
||||
name: hyperframes-animation
|
||||
description: "All animation knowledge for HyperFrames — atomic motion rules, multi-phase scene blueprints, scene transitions, broader motion-design techniques, AND the seven runtime adapters (GSAP default, plus Lottie, Three.js, Anime.js, CSS keyframes, Web Animations API, TypeGPU). Use for any motion or animation task: pick 2-4 rules and compose, or load a blueprint, or look up runtime-specific API (e.g. GSAP eases / Lottie player / Three.js mixer). Also covers auditing an existing composition's choreography (animation map) and 24 named text-animation effects. HyperFrames-native: single paused timeline, seek-safe, deterministic."
|
||||
---
|
||||
|
||||
# HyperFrames Animation
|
||||
|
||||
All motion knowledge in one skill: **rules** (atomic recipes), **blueprints** (multi-phase scene templates), **transitions** (scene-to-scene), **techniques** (broader motion-design patterns), and **adapters** (per-runtime APIs).
|
||||
|
||||
For the composition contract (data attributes, sub-compositions, determinism) see `hyperframes-core`.
|
||||
|
||||
## Default: compose atomic rules
|
||||
|
||||
Pick 2-4 rules from `rules-index.md`, glue them together with a single paused GSAP timeline, done. This is faster and produces less code than starting from a blueprint.
|
||||
|
||||
## Load a blueprint when
|
||||
|
||||
- The scene matches an existing pre-designed multi-phase template (brand-reveal, social-proof, etc.) and reusing its phase pipeline saves real authoring time
|
||||
- You want runnable ground-truth code for a complex 4-5 phase choreography
|
||||
|
||||
Blueprints live in `blueprints-index.md`. Each entry points to `blueprints/<id>.md` (recipe). Do not read it speculatively; load it when you've already decided you need scene-level orchestration.
|
||||
|
||||
## Routing
|
||||
|
||||
| Want to… | Read |
|
||||
| ------------------------------------------------------------------------------ | --------------------------------------------------- |
|
||||
| Pick an atomic motion pattern by trigger / tag | `rules-index.md` |
|
||||
| Read one rule's full HTML / CSS / GSAP recipe | `rules/<name>.md` |
|
||||
| Pick a multi-phase scene template | `blueprints-index.md` |
|
||||
| Read one blueprint's full recipe | `blueprints/<id>.md` |
|
||||
| Author a scene transition (CSS-driven, between two clips) | `transitions/overview.md`, `transitions/catalog.md` |
|
||||
| Look up a broader motion-design technique | `techniques.md` |
|
||||
| Analyze an existing composition's animation map | `scripts/animation-map.mjs` |
|
||||
| GSAP API — timeline / tweens / position parameters | `adapters/gsap.md` |
|
||||
| GSAP — drop-in effect recipes | `rules/gsap-effects.md` |
|
||||
| GSAP — transforms / perf | `adapters/gsap-transforms-and-perf.md` |
|
||||
| GSAP — eases / stagger | `adapters/gsap-easing-and-stagger.md` |
|
||||
| GSAP — timeline / labels | `adapters/gsap-timeline-and-labels.md` |
|
||||
| Lottie / dotLottie (After Effects exports, `window.__hfLottie`) | `adapters/lottie.md` |
|
||||
| Three.js / WebGL (3D scenes, `AnimationMixer`, `hf-seek`) | `adapters/three.md` |
|
||||
| Anime.js (`window.__hfAnime`) | `adapters/animejs.md` |
|
||||
| CSS keyframes (`animation-delay` / `play-state` / `fill-mode`) | `adapters/css-animations.md` |
|
||||
| Web Animations API (`element.animate()`, `currentTime` seek) | `adapters/waapi.md` |
|
||||
| TypeGPU / WebGPU (`navigator.gpu`, WGSL, compute pipelines) | `adapters/typegpu.md` |
|
||||
| HTML-as-texture + WebGL/GLSL post-fx (capture live DOM via `drawElementImage`) | `adapters/html-in-canvas-patterns.md` |
|
||||
| Named text-animation effects (24 IDs via external `animate-text` skill) | `adapters/animate-text.md` |
|
||||
|
||||
## Picking a runtime
|
||||
|
||||
- **GSAP** is the default for 95% of motion work — covers timeline orchestration, transforms, easing, stagger. All atomic rules in this skill are GSAP-based.
|
||||
- **Lottie** when an asset has its own pre-baked timeline (typically After Effects exports).
|
||||
- **Three.js** for 3D scenes, camera motion, shader-driven visuals.
|
||||
- **Anime.js** for lightweight tweening when GSAP is overkill.
|
||||
- **CSS** for simple repeated motifs, decoration, shimmer — no JavaScript animation cost.
|
||||
- **WAAPI** for native browser keyframes without a GSAP dependency.
|
||||
- **TypeGPU / WebGPU** for GPU-rendered canvases (particles, liquid glass, custom shaders).
|
||||
|
||||
Multiple runtimes can coexist in one composition. Each registers its instances on the runtime-specific global so HyperFrames can seek all of them in one pass.
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
**Prerequisite: `hyperframes-core` → Non-Negotiable Rules** (single paused timeline, `data-duration` governs length, no `Math.random` / `Date.now` / `performance.now`, no `repeat: -1`, no `gsap.set` on later-scene clips, no `display` / `visibility` animation, no timeline construction inside `async` / `setTimeout` / `Promise`). Don't restate those here.
|
||||
|
||||
Animation-craft additions on top of core's contract:
|
||||
|
||||
- **Pre-calculated layout constants** — never derive positions from `getBoundingClientRect()` at tween time. Tween-time DOM measurements desync because the renderer samples in parallel; compute coordinates once at composition setup and reuse.
|
||||
- **Spatial motion uses GSAP transform aliases only** (`x`, `y`, `scale`, `rotation`). Core's allowlist also permits `opacity` / `color` / `backgroundColor` / `borderRadius` for non-spatial property tweens — but never `width` / `height` / `top` / `left` for layout changes.
|
||||
|
||||
## Scripts
|
||||
|
||||
```bash
|
||||
node skills/hyperframes-animation/scripts/animation-map.mjs <composition-dir> \
|
||||
--out <composition-dir>/.hyperframes/anim-map
|
||||
```
|
||||
|
||||
Reads every GSAP timeline registered on `window.__timelines`, enumerates tweens, samples bboxes, computes flags, outputs `animation-map.json`. Use it to audit choreography (dead zones, stagger consistency, lifecycle warnings) after authoring.
|
||||
|
||||
`animation-map.mjs` resolves helper packages from the current project first, then can bootstrap the bundled HyperFrames package version. Set `HYPERFRAMES_SKILL_PKG_VERSION=<version>` only when running the skill outside the bundled CLI/skill install and you need to pin that bootstrap version explicitly.
|
||||
|
||||
## See Also
|
||||
|
||||
- `hyperframes-core` — composition structure, data attributes, sub-compositions, deterministic render contract
|
||||
- `hyperframes-creative` — palettes, typography, narration, beat planning (non-animation creative direction)
|
||||
- `hyperframes-cli` — `npx hyperframes lint / validate / inspect / preview / render`
|
||||
@@ -0,0 +1,64 @@
|
||||
# Text Effects — Reference
|
||||
|
||||
For deterministic text-animation specs (e.g., `typewriter` at exact `240ms / 46ms stagger / steps(1, end) easing`), this skill defers to the separate **`animate-text`** skill maintained by Pixel Point at [github.com/pixel-point/animate-text](https://github.com/pixel-point/animate-text). It provides a catalog of 24 named text effects with portable contracts and per-library implementation recipes (GSAP, Anime.js, WAAPI).
|
||||
|
||||
**We do NOT ship the catalog inside this repo.** Pixel Point's `animate-text` is the source of truth; vendoring its files here would violate the upstream's licensing (no explicit license declared upstream as of this writing). Loading the skill separately keeps the legal picture clean while giving you the same catalog.
|
||||
|
||||
## How to use it
|
||||
|
||||
When a beat needs a deterministic text animation, load the upstream skill alongside this one:
|
||||
|
||||
```bash
|
||||
# In your project root, install the upstream skill into .agents/skills/
|
||||
npx skills add pixel-point/animate-text
|
||||
```
|
||||
|
||||
Or in a skill-aware agent runtime, the skill is invoked by name:
|
||||
|
||||
```
|
||||
/animate-text
|
||||
```
|
||||
|
||||
Once installed, the specs live at:
|
||||
|
||||
```
|
||||
.agents/skills/animate-text/assets/effects/<id>.json # per-library implementation recipe
|
||||
.agents/skills/animate-text/assets/specs/<id>.json # portable motion contract
|
||||
```
|
||||
|
||||
Sub-agents reading those files get exact GSAP timings, easing strings, DOM split rules, and stagger algorithms — no creative invention needed.
|
||||
|
||||
## When you don't need the upstream skill
|
||||
|
||||
If a beat's text animation is simple enough to describe in prose ("headline fades up word-by-word, 80ms stagger"), implement it inline using the GSAP knowledge already in these skills (`hyperframes-creative` → `references/motion-principles.md` and `references/beat-direction.md`; `hyperframes-animation` → `techniques.md`, entry #4 "Per-Word Kinetic Typography"). The upstream catalog is most valuable when:
|
||||
|
||||
- You want a specific NAMED effect across multiple beats (so they feel like one design system, not one-offs)
|
||||
- You're choosing between several similar effects (typewriter vs per-character-rise vs bottom-up-letters) and want to see all 24 in one place
|
||||
- You need layout-aware effects (`kinetic-center-build`, `short-slide-right`, `short-slide-down`) where parameters alone aren't enough — those ship with custom layout algorithms
|
||||
|
||||
## Effect names — vocabulary (do NOT use this as the implementation source)
|
||||
|
||||
For convenience while writing storyboards: the upstream skill provides 24 effects. Their IDs are listed here so you can name them in `STORYBOARD.md` even before loading the upstream skill. **The implementation specs are in the upstream skill, not here.**
|
||||
|
||||
- **Per-character (7):** soft-blur-in, per-character-rise, typewriter, bottom-up-letters, top-down-letters, stagger-from-center, stagger-from-edges
|
||||
- **Per-word (8):** per-word-crossfade, spring-scale-in, shared-axis-y, blur-out-up, kinetic-center-build, short-slide-right, short-slide-down, depth-parallax-words
|
||||
- **Per-line (2):** mask-reveal-up, line-by-line-slide
|
||||
- **Whole element (7):** micro-scale-fade, shimmer-sweep, fade-through, shared-axis-z, scale-down-fade, focus-blur-resolve, shared-axis-x
|
||||
|
||||
For descriptions, durations, easing curves, and the per-library recipes: load `/animate-text` and read its own catalog page.
|
||||
|
||||
## In the storyboard
|
||||
|
||||
Every text element in every beat can name an effect by ID, e.g.:
|
||||
|
||||
```markdown
|
||||
**Text Animations:**
|
||||
|
||||
- Main headline: `kinetic-center-build`
|
||||
- Eyebrow label: `soft-blur-in`
|
||||
- Body copy 3 lines: `mask-reveal-up`
|
||||
```
|
||||
|
||||
Sub-agents implementing the beat will load `/animate-text` if it's not already loaded, then read the spec for each named effect from the upstream skill's files.
|
||||
|
||||
If the upstream skill isn't available (offline build, network restrictions, agent runtime that doesn't support skill loading), sub-agents fall back to implementing the effect from the description alone — using GSAP knowledge plus the effect ID as a description of intent (e.g., "typewriter" = per-character stepped reveal with no interpolation).
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
name: hyperframes-animejs
|
||||
description: Anime.js adapter patterns for HyperFrames. Use when writing Anime.js animations or timelines inside HyperFrames compositions, registering animations on window.__hfAnime, making Anime.js seek-driven and deterministic, or translating Anime.js examples into render-safe HyperFrames HTML.
|
||||
---
|
||||
|
||||
# Anime.js for HyperFrames
|
||||
|
||||
HyperFrames can seek Anime.js instances through its `animejs` runtime adapter. The composition owns the animation objects; HyperFrames owns the clock.
|
||||
|
||||
## Contract
|
||||
|
||||
- Create animations or timelines synchronously during composition initialization.
|
||||
- Set `autoplay: false` so Anime.js does not advance on its own clock.
|
||||
- Register every returned animation or timeline on `window.__hfAnime`.
|
||||
- Use finite durations and loop counts.
|
||||
- Avoid callbacks that mutate DOM based on wall-clock time, network state, or unseeded randomness.
|
||||
|
||||
The adapter seeks every registered instance with `instance.seek(timeMs)`, where `timeMs` is HyperFrames time in milliseconds.
|
||||
|
||||
## Basic Pattern
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/animejs@4.0.2/lib/anime.iife.min.js"></script>
|
||||
<script>
|
||||
const anim = anime({
|
||||
targets: ".mark",
|
||||
translateX: 280,
|
||||
rotate: "1turn",
|
||||
opacity: [0, 1],
|
||||
duration: 1200,
|
||||
easing: "easeOutExpo",
|
||||
autoplay: false,
|
||||
});
|
||||
|
||||
window.__hfAnime = window.__hfAnime || [];
|
||||
window.__hfAnime.push(anim);
|
||||
</script>
|
||||
```
|
||||
|
||||
## Timeline Pattern
|
||||
|
||||
```html
|
||||
<script>
|
||||
const tl = anime.timeline({
|
||||
autoplay: false,
|
||||
easing: "easeOutCubic",
|
||||
});
|
||||
|
||||
tl.add({
|
||||
targets: ".title",
|
||||
translateY: [40, 0],
|
||||
opacity: [0, 1],
|
||||
duration: 650,
|
||||
}).add(
|
||||
{
|
||||
targets: ".accent",
|
||||
scaleX: [0, 1],
|
||||
duration: 450,
|
||||
},
|
||||
250,
|
||||
);
|
||||
|
||||
window.__hfAnime = window.__hfAnime || [];
|
||||
window.__hfAnime.push(tl);
|
||||
</script>
|
||||
```
|
||||
|
||||
## Module Builds
|
||||
|
||||
If you use an ES module build, the adapter does not care how the instance was created. It only needs the returned object to expose `seek()`, `pause()`, and preferably `play()`:
|
||||
|
||||
```html
|
||||
<script type="module">
|
||||
import { animate } from "https://cdn.jsdelivr.net/npm/animejs/+esm";
|
||||
|
||||
const anim = animate(".chip", {
|
||||
x: "18rem",
|
||||
duration: 900,
|
||||
autoplay: false,
|
||||
});
|
||||
|
||||
window.__hfAnime = window.__hfAnime || [];
|
||||
window.__hfAnime.push(anim);
|
||||
</script>
|
||||
```
|
||||
|
||||
## Good Uses
|
||||
|
||||
- Small SVG and DOM flourishes where Anime.js syntax is compact.
|
||||
- Imported Anime.js examples that can be made seek-driven.
|
||||
- Multiple independent micro-animations pushed into the same registry.
|
||||
|
||||
Use GSAP for complex scene sequencing unless the user specifically asks for Anime.js. GSAP is still the primary HyperFrames authoring path.
|
||||
|
||||
## Avoid
|
||||
|
||||
- Leaving `autoplay` at the Anime.js default.
|
||||
- Depending on `anime.running` auto-discovery instead of explicit `window.__hfAnime.push(...)`.
|
||||
- Infinite loops. Compute a finite repeat count from the composition duration.
|
||||
- Building animations in timers, promises, event handlers, or after async asset loads.
|
||||
|
||||
## Validation
|
||||
|
||||
After editing a composition that uses Anime.js:
|
||||
|
||||
```bash
|
||||
npx hyperframes lint
|
||||
npx hyperframes check
|
||||
```
|
||||
|
||||
## Credits And References
|
||||
|
||||
- HyperFrames adapter source: `packages/core/src/runtime/adapters/animejs.ts`.
|
||||
- Anime.js documentation for `autoplay`, `pause()`, and `seek()`: https://animejs.com/documentation/
|
||||
@@ -0,0 +1,143 @@
|
||||
---
|
||||
name: hyperframes-css-animations
|
||||
description: CSS animation adapter patterns for HyperFrames. Use when authoring CSS keyframes, animation-delay based timing, animation-fill-mode, animation-play-state, or CSS-only motion that HyperFrames must seek deterministically during preview and rendering.
|
||||
---
|
||||
|
||||
# CSS Animations for HyperFrames
|
||||
|
||||
HyperFrames can seek CSS keyframe animations through its `css` runtime adapter. Use this for simple repeated motifs, background motion, shimmer, glow, masks, and non-sequenced decoration.
|
||||
|
||||
For scene choreography, GSAP is usually clearer. CSS animations work best when the motion belongs to one element and has a fixed duration.
|
||||
|
||||
## Contract
|
||||
|
||||
- Put the animated element in the DOM before runtime initialization finishes.
|
||||
- Give timed elements a `data-start` value so local animation time matches the clip.
|
||||
- Use finite `animation-duration` and `animation-iteration-count` because the negative-delay fallback cannot represent unbounded duration in environments without WAAPI-backed CSS animations.
|
||||
- Prefer `animation-fill-mode: both` so seeked states hold before and after active motion.
|
||||
- Avoid wall-clock JavaScript, hover-triggered state, and class toggles that depend on user events.
|
||||
|
||||
The adapter discovers elements with computed `animation-name`, seeks their browser `Animation` handles when available, and falls back to pausing with negative `animation-delay`.
|
||||
|
||||
## Basic Pattern
|
||||
|
||||
```html
|
||||
<div
|
||||
id="pulse-ring"
|
||||
class="clip pulse-ring"
|
||||
data-start="0"
|
||||
data-duration="4"
|
||||
data-track-index="2"
|
||||
></div>
|
||||
|
||||
<style>
|
||||
.pulse-ring {
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.7);
|
||||
border-radius: 50%;
|
||||
animation-name: pulse-ring;
|
||||
animation-duration: 1200ms;
|
||||
animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
|
||||
animation-iteration-count: 3;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.82);
|
||||
}
|
||||
35% {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(1.18);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Stagger Pattern
|
||||
|
||||
Use CSS custom properties to avoid duplicating keyframes:
|
||||
|
||||
```html
|
||||
<div class="clip dots" data-start="1" data-duration="3" data-track-index="3">
|
||||
<span style="--i: 0"></span>
|
||||
<span style="--i: 1"></span>
|
||||
<span style="--i: 2"></span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dots span {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: 10px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
animation: dot-pop 900ms ease-out both;
|
||||
animation-delay: calc(var(--i) * 120ms);
|
||||
}
|
||||
|
||||
@keyframes dot-pop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(18px) scale(0.75);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Good Uses
|
||||
|
||||
- Decorative loops with a known repeat count.
|
||||
- Mask, glow, shimmer, grain, and subtle parallax layers.
|
||||
- Simple one-element entrances where a full JS timeline would be excessive.
|
||||
|
||||
## Avoid
|
||||
|
||||
- Infinite CSS animations unless you have verified the browser exposes seekable WAAPI-backed CSS animation handles. Prefer a finite iteration count covering the visible duration. If you do use `infinite`, add `data-duration` to the root element — see Composition Duration below.
|
||||
- Animating layout properties like `top`, `left`, `width`, or `height` when transforms work.
|
||||
- Relying on hover, focus, scroll, or media queries to trigger render-critical motion.
|
||||
- Changing animation classes after startup unless another deterministic timeline controls that change.
|
||||
|
||||
## Composition Duration
|
||||
|
||||
The render engine needs to know the composition's total length. GSAP timelines report this automatically; CSS-only compositions have no timeline object, so the runtime infers duration from the longest running animation's computed end time (`animation-delay` + `animation-duration` × finite `animation-iteration-count`, per element with `data-start` added as an offset). `data-duration` on the root element is optional whenever every CSS animation on the page is finite — you don't need to add it just because the composition is CSS-driven.
|
||||
|
||||
`animation-iteration-count: infinite` (or any unresolved/unbounded animation) has no finite end time, so it cannot be auto-inferred. If the composition's only animation is infinite, you **must** add `data-duration="<seconds>"` to the root `[data-composition-id]` element with your intended total length — `npx hyperframes lint` errors on this case (`root_composition_missing_duration_source`) precisely because there is nothing for the runtime to infer.
|
||||
|
||||
```html
|
||||
<div
|
||||
data-composition-id="root"
|
||||
data-start="0"
|
||||
data-duration="6"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
>
|
||||
<div class="clip spinner" data-start="0" style="animation: spin 1s linear infinite"></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
After editing CSS animation compositions:
|
||||
|
||||
```bash
|
||||
npx hyperframes lint
|
||||
npx hyperframes check
|
||||
```
|
||||
|
||||
## Credits And References
|
||||
|
||||
- HyperFrames adapter source: `packages/core/src/runtime/adapters/css.ts`.
|
||||
- Duration auto-inference: `packages/core/src/runtime/init.ts` (`resolveAdapterDurationFloorSeconds`), `getInferredDurationSeconds` in the adapter above.
|
||||
- MDN CSS animation documentation: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/animation
|
||||
- MDN `animation-fill-mode`: https://developer.mozilla.org/en-US/docs/Web/CSS/animation-fill-mode
|
||||
@@ -0,0 +1,199 @@
|
||||
# Easing, Stagger, and Function-Based Values
|
||||
|
||||
## Easing
|
||||
|
||||
Built-in eases: `power1`, `power2`, `power3`, `power4`, `back`, `bounce`, `circ`, `elastic`, `expo`, `sine`, `none`.
|
||||
|
||||
Each has `.in`, `.out`, `.inOut` variants.
|
||||
|
||||
| Ease | Use for |
|
||||
| ------------------------------------------ | ----------------------------------------------------------------------------------------------- |
|
||||
| `power1.out`, `power2.out` | Gentle motion for secondary elements (a caption fade, a small shift). NOT the entrance default. |
|
||||
| `power3.out` (house default), `power4.out` | The standard long-tail settle. Entrances, title cards, hero reveals. |
|
||||
| `sine.inOut` | Long, slow, calm motion. Crossfades, ambient drift. |
|
||||
| `back.out(1.7)` | Overshoot then settle. RARE — explicitly-playful register only, never a default. |
|
||||
| `elastic.out(1, 0.3)` | Springy bounce. Same playful-only rule; prefer a baked spring (see Spring Eases below). |
|
||||
| `expo.inOut` | Snappy, dramatic. Quick transitions between hero scenes. |
|
||||
| `none` (linear) | Camera moves with timed counterpoint, mechanical motion. |
|
||||
|
||||
Pick `.out` for entrances, `.in` for exits, `.inOut` for symmetric moves and continuous motion.
|
||||
|
||||
**Smooth beats bouncy** — the motion doctrine (`rules/spring-pop-entrance.md`, the workflows' `motion-language.md`): entrances default to `power3.out` or the baked critically-damped spring (see Spring Eases below); overshoot eases (`back` / `elastic` / `bounce`) are a rare, explicitly-playful register, never the house style.
|
||||
|
||||
## Easing Vocabulary (character & mood)
|
||||
|
||||
Easings are tone of voice: a video that only whispers is boring; one that varies between whisper, normal, and punch is engaging. A composition should draw on ~3 easing characters across its beats — but vary **within the smooth families by energy** (`sine` / `power1` calm → `power3` standard → `power4` / `expo` punch); don't reach for overshoot to add variety. Overshoot is a _register_ (explicitly playful), not a spice. One ease everywhere reads flat; bounce everywhere reads cheap — the second failure is worse.
|
||||
|
||||
The full palette by character (each family has `.in`, `.out`, `.inOut` variants):
|
||||
|
||||
| Family | Character | Typical use |
|
||||
| -------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `power1`–`power4` | Gentle (1) to aggressive (4) acceleration curves | General purpose. **power3 is the house workhorse**; power2 for gentle secondary motion, power4 for dramatic snaps |
|
||||
| `back(N)` | Overshoot then settle. N controls how far past the target (1=subtle, 4=wild) | RARE — explicitly-playful register only, never a default. Keep N ≤ 2; prefer a baked spring at ζ 0.6–0.7 (physical settle, see Spring Eases) |
|
||||
| `elastic(amp, freq)` | Spring bounce. amp=magnitude, freq=oscillation speed | RARE — same playful-only rule; the baked spring (below) is the physical version |
|
||||
| `bounce` | Ball-drop bouncing | RARE — physical-comedy register only (something literally dropping) |
|
||||
| `expo` | Extreme acceleration curve (much steeper than power4) | Premium/luxury reveals, dramatic entrances |
|
||||
| `sine` | Smooth, organic, no hard edges | Ambient float, breathing, Ken Burns, anything that loops. `.inOut` for yoyo motion |
|
||||
| `circ` | Circular acceleration (starts very fast, ends very gentle or vice versa) | Camera moves, scene transitions, orbital motion |
|
||||
| `steps(N)` | Discrete N-step jumps, no interpolation | Typing effects, cursor blink, counter ticks, retro/digital aesthetics |
|
||||
|
||||
**Mood mapping:** Match easing character to the beat's emotional content. Smooth/organic easings (`sine`, `power1`) feel contemplative and drifting. Aggressive deceleration (`power4.out`, `expo.out`) feels snappy and confident. Spring overshoot (`back.out`) feels bouncy and physical — but bouncy is a register, not an emphasis tool; reach for it only on explicitly-playful beats. The storyboard's mood description should guide which character fits — not a formula.
|
||||
|
||||
## Defaults
|
||||
|
||||
```javascript
|
||||
const tl = gsap.timeline({
|
||||
paused: true,
|
||||
defaults: { duration: 0.6, ease: "power3.out" }, // the house settle — smooth beats bouncy
|
||||
});
|
||||
```
|
||||
|
||||
Or globally:
|
||||
|
||||
```javascript
|
||||
gsap.defaults({ duration: 0.6, ease: "power3.out" });
|
||||
```
|
||||
|
||||
Setting defaults at timeline scope is preferred — it documents the motion language of that composition in one place.
|
||||
|
||||
## Spring Eases (baked physics, seek-safe)
|
||||
|
||||
The "iOS feel" is a **damped spring's velocity curve**, not a bounce: a fast launch into a long asymptotic settle. Well-made system animations are critically damped or close to it — they barely overshoot, or don't at all. `power3.out` / `expo.out` approximate that curve; when you want the exact one — or a _physical_ overshoot for the rare playful register — bake the spring's closed-form solution into a function ease.
|
||||
|
||||
Why not a real-time spring library: an interactive spring is a stateful integrator (velocity accumulates frame to frame), which cannot be seeked deterministically — you'd have to simulate frames 0…N−1 to render frame N. The closed form below is a **pure function of progress** — no state, nothing to desync, seek-safe by construction. This is also why interaction-lib spring solvers are banned in compositions.
|
||||
|
||||
```javascript
|
||||
// springEase — a damped spring's exact position curve as a GSAP ease.
|
||||
// response ≈ seconds one oscillation would take (0.3–0.6 for entrances)
|
||||
// dampingFraction 1.0 = critically damped — smooth settle, NO overshoot (house default)
|
||||
// 0.80–0.85 ≈ the iOS system register — ~1–1.5% overshoot, felt not seen
|
||||
// 0.60–0.70 = explicitly playful — ~5–10% overshoot (rare; replaces back.out)
|
||||
function springEase({ response = 0.5, dampingFraction = 1 } = {}) {
|
||||
const w = (2 * Math.PI) / response; // undamped natural frequency
|
||||
const z = dampingFraction;
|
||||
let pos; // x(t): 0 → 1, starting at rest (v0 = 0)
|
||||
if (z < 1) {
|
||||
const wd = w * Math.sqrt(1 - z * z);
|
||||
pos = (t) => 1 - Math.exp(-z * w * t) * (Math.cos(wd * t) + ((z * w) / wd) * Math.sin(wd * t));
|
||||
} else if (z > 1) {
|
||||
const wo = w * Math.sqrt(z * z - 1);
|
||||
pos = (t) =>
|
||||
1 - Math.exp(-z * w * t) * (Math.cosh(wo * t) + ((z * w) / wo) * Math.sinh(wo * t));
|
||||
} else {
|
||||
pos = (t) => 1 - Math.exp(-w * t) * (1 + w * t);
|
||||
}
|
||||
// Settle time: last moment the curve sits outside ±0.1% of target.
|
||||
// Fixed-step scan, runs once at setup — deterministic (no Math.random / Date.now).
|
||||
const EPS = 0.001;
|
||||
const rate = z <= 1 ? z * w : (z - Math.sqrt(z * z - 1)) * w; // slowest decay mode
|
||||
const SCAN = 12 / rate;
|
||||
const N = 4800;
|
||||
let T = SCAN;
|
||||
for (let i = N; i >= 0; i--) {
|
||||
const t = (i / N) * SCAN;
|
||||
if (Math.abs(1 - pos(t)) > EPS) {
|
||||
T = ((i + 1) / N) * SCAN;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const xT = pos(T);
|
||||
return {
|
||||
duration: T, // use as the tween's duration — the settle time IS the physics
|
||||
ease: (p) => pos(p * T) + p * (1 - xT), // normalized so ease(1) === 1 exactly
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Usage — take **both** the ease and the duration from the helper (the settle time is part of the physics; overriding the duration just re-times the same curve, so tune speed via `response` instead):
|
||||
|
||||
```javascript
|
||||
const settle = springEase({ response: 0.4 }); // critically damped → duration ≈ 0.59s
|
||||
tl.fromTo(
|
||||
"#hero",
|
||||
{ scale: 0, opacity: 0 },
|
||||
{ scale: 1, opacity: 1, duration: settle.duration, ease: settle.ease },
|
||||
0.2,
|
||||
);
|
||||
```
|
||||
|
||||
| dampingFraction | overshoot | register |
|
||||
| ----------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **1.0 (default)** | none (monotone) | The house settle — the exact curve `power3.out` approximates. Product / enterprise / serious tone. |
|
||||
| 0.80–0.85 | ~1–1.5% | "Alive, not bouncy" — the iOS system default register. The overshoot is felt, not seen. |
|
||||
| 0.60–0.70 | ~5–10% | Explicitly-playful ONLY (same rule as `back.out`, which this replaces — a spring's second-order settle reads physical where `back` reads cartoon). |
|
||||
| < 0.55 | > 12% | Don't. Cartoon-wobble territory. |
|
||||
|
||||
| response | duration (ζ=1) | feel |
|
||||
| --------- | -------------- | ------------------------------------------------------------ |
|
||||
| 0.25–0.35 | 0.37–0.51s | tight snap — chips, small UI |
|
||||
| 0.35–0.50 | 0.51–0.74s | standard entrance |
|
||||
| 0.50–0.70 | 0.74–1.03s | weighted hero landing — check the `t ≤ 0.5s` visibility rule |
|
||||
|
||||
Craft notes:
|
||||
|
||||
- **ζ=1 vs `power3.out`**: the true spring front-loads harder (~67% vs ~58% travelled at quarter-time) and settles on a longer asymptotic tail; max shape difference ~11%. That long tail is the "premium" read — use it when the settle IS the shot (a wordmark landing, a final lockup).
|
||||
- **At ζ<1, overshooting curves go on transforms only** — never on `opacity` (it would push past 1) or color. Split opacity onto its own `power2.out` tween at the same timeline position.
|
||||
- **Doctrine unchanged**: ζ below ~0.8 is still the rare, explicitly-playful exception (`rules/spring-pop-entrance.md`). The default of this section is ζ=1 — real spring physics is not a license for bounce.
|
||||
|
||||
## Stagger
|
||||
|
||||
```javascript
|
||||
gsap.fromTo(".item", { y: 24, opacity: 0 }, { y: 0, opacity: 1, duration: 0.5, stagger: 0.08 });
|
||||
```
|
||||
|
||||
Object form:
|
||||
|
||||
```javascript
|
||||
gsap.fromTo(
|
||||
".item",
|
||||
{ y: 24, opacity: 0 },
|
||||
{
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
stagger: {
|
||||
each: 0.08, // delay between each
|
||||
from: "center", // "start" | "end" | "center" | "edges" | "random" | index
|
||||
amount: 0.6, // total stagger time (overrides each if both set)
|
||||
grid: "auto", // for 2D stagger
|
||||
axis: "x" | "y",
|
||||
},
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
Prefer `stagger` over N separate tweens with manual delays — it stays correct when the target count or order changes. Use `fromTo()` rather than `from()` so the start state is explicit (see `gsap-timeline-and-labels.md` → sub-composition entrances).
|
||||
|
||||
## Function-Based Values
|
||||
|
||||
Any var can be a function `(index, target, targets) => value`:
|
||||
|
||||
```javascript
|
||||
gsap.to(".item", {
|
||||
x: (i, target, targets) => i * 50,
|
||||
rotation: (i) => (i % 2 === 0 ? 5 : -5),
|
||||
stagger: 0.1,
|
||||
});
|
||||
```
|
||||
|
||||
Use this for per-element values that depend on index, attributes, or measured size. Cheaper and more idiomatic than building tweens in a loop.
|
||||
|
||||
## gsap.matchMedia (preview only)
|
||||
|
||||
`matchMedia` runs setup only when a media query matches and auto-reverts when it stops matching. It is useful for **preview** in the browser at different viewport sizes, and for `prefers-reduced-motion`. It is **not** a substitute for rendering at the composition's actual `data-width`/`data-height` — HyperFrames renders at a fixed viewport.
|
||||
|
||||
```javascript
|
||||
let mm = gsap.matchMedia();
|
||||
mm.add(
|
||||
{
|
||||
isDesktop: "(min-width: 800px)",
|
||||
reduceMotion: "(prefers-reduced-motion: reduce)",
|
||||
},
|
||||
(context) => {
|
||||
const { isDesktop, reduceMotion } = context.conditions;
|
||||
gsap.to(".box", {
|
||||
rotation: isDesktop ? 360 : 180,
|
||||
duration: reduceMotion ? 0 : 2,
|
||||
});
|
||||
},
|
||||
);
|
||||
```
|
||||
@@ -0,0 +1,96 @@
|
||||
# Timelines and Labels
|
||||
|
||||
HyperFrames is a seek-driven runtime. Build one paused timeline per composition, attach it to `window.__timelines["<composition-id>"]`, and let HyperFrames seek it. Never call `.play()` for render-critical motion.
|
||||
|
||||
## Creating a Timeline
|
||||
|
||||
```javascript
|
||||
const tl = gsap.timeline({
|
||||
paused: true,
|
||||
defaults: { duration: 0.5, ease: "power3.out" },
|
||||
});
|
||||
|
||||
tl.to(".a", { x: 100 }).to(".b", { y: 50 }).to(".c", { opacity: 0 });
|
||||
```
|
||||
|
||||
Timeline options:
|
||||
|
||||
- **paused: true** — required in HyperFrames. The framework drives the playhead.
|
||||
- **repeat**, **yoyo** — apply to the whole timeline. `repeat: -1` is forbidden; use finite counts.
|
||||
- **defaults** — vars merged into every child tween. Use this instead of repeating `ease` and `duration` on every line.
|
||||
|
||||
## Position Parameter
|
||||
|
||||
The third argument to `.to()`/`.from()`/`.fromTo()` controls placement on the timeline:
|
||||
|
||||
| Form | Meaning |
|
||||
| -------------- | ------------------------------------ |
|
||||
| `0`, `1.5` | Absolute time in seconds |
|
||||
| `"+=0.5"` | 0.5s after the end of the timeline |
|
||||
| `"-=0.2"` | 0.2s before the end of the timeline |
|
||||
| `"intro"` | At the `intro` label |
|
||||
| `"intro+=0.3"` | 0.3s after the `intro` label |
|
||||
| `"<"` | Same start as the previous tween |
|
||||
| `">"` | Right after the previous tween ends |
|
||||
| `"<0.2"` | 0.2s after the previous tween starts |
|
||||
| `">-0.1"` | 0.1s before the previous tween ends |
|
||||
|
||||
```javascript
|
||||
tl.to(".a", { x: 100 }, 0);
|
||||
tl.to(".b", { y: 50 }, "<"); // same start as .a
|
||||
tl.to(".c", { opacity: 0 }, "<0.2"); // 0.2s after .b starts
|
||||
```
|
||||
|
||||
Prefer the position parameter over `delay:` — it composes naturally and survives refactors that re-order tweens.
|
||||
|
||||
## Labels
|
||||
|
||||
```javascript
|
||||
tl.addLabel("intro", 0);
|
||||
tl.to(".a", { x: 100 }, "intro");
|
||||
|
||||
tl.addLabel("outro", "+=0.5");
|
||||
tl.to(".a", { opacity: 0 }, "outro");
|
||||
```
|
||||
|
||||
Labels make a long timeline readable and let multiple tweens converge on the same beat without re-typing absolute times.
|
||||
|
||||
## Nesting Timelines
|
||||
|
||||
```javascript
|
||||
const master = gsap.timeline({ paused: true });
|
||||
|
||||
const child = gsap.timeline();
|
||||
child.to(".a", { x: 100 }).to(".b", { y: 50 });
|
||||
|
||||
master.add(child, 0);
|
||||
```
|
||||
|
||||
In HyperFrames, **do not** nest sub-composition timelines into the host. Sub-compositions loaded via `data-composition-src` are seeked independently by HyperFrames from their own `data-start`. Nesting is only for grouping pieces of the _same_ composition's timeline.
|
||||
|
||||
## Inside Sub-Compositions: prefer `fromTo` over `from`
|
||||
|
||||
For entrance tweens inside a sub-composition, prefer `gsap.fromTo()` over `gsap.from()`:
|
||||
|
||||
```javascript
|
||||
// Sub-composition entrance — survives re-seek cleanly
|
||||
tl.fromTo(".title", { y: 60, opacity: 0 }, { y: 0, opacity: 1, duration: 0.6 }, 0.2);
|
||||
```
|
||||
|
||||
Why: HyperFrames re-seeks the sub-composition every time its host clip becomes visible. `gsap.from()` snapshots the starting state at **registration time** (page load); when the playhead jumps back past `data-start`, that snapshot can desync from the actual CSS state and the element renders in the wrong position. `gsap.fromTo()` declares both endpoints explicitly, so the seek-back always produces the same start state.
|
||||
|
||||
In top-level (standalone) compositions either form works — there's no re-seek-through-mount cycle.
|
||||
|
||||
## Playback Control (debug / preview only)
|
||||
|
||||
```javascript
|
||||
tl.play();
|
||||
tl.pause();
|
||||
tl.reverse();
|
||||
tl.restart();
|
||||
tl.time(2);
|
||||
tl.progress(0.5);
|
||||
tl.kill();
|
||||
```
|
||||
|
||||
These are useful when previewing in the browser. In rendered output HyperFrames calls `seek()` internally — your timeline must produce identical state for the same time value every time it is seeked.
|
||||
@@ -0,0 +1,128 @@
|
||||
# Transforms and Performance
|
||||
|
||||
## Transform Aliases
|
||||
|
||||
Prefer GSAP's transform aliases over raw `transform` strings:
|
||||
|
||||
| GSAP property | Equivalent |
|
||||
| --------------------------- | --------------------- |
|
||||
| `x`, `y`, `z` | `translateX/Y/Z` (px) |
|
||||
| `xPercent`, `yPercent` | `translateX/Y` in `%` |
|
||||
| `scale`, `scaleX`, `scaleY` | `scale` |
|
||||
| `rotation` | `rotate` (deg) |
|
||||
| `rotationX`, `rotationY` | 3D rotate |
|
||||
| `skewX`, `skewY` | `skew` |
|
||||
| `transformOrigin` | `transform-origin` |
|
||||
|
||||
Aliases let GSAP track and interpolate each axis independently, which prevents accidental overwrites between separate tweens on the same element.
|
||||
|
||||
## autoAlpha
|
||||
|
||||
Prefer `autoAlpha` over `opacity` for show/hide:
|
||||
|
||||
```javascript
|
||||
gsap.to(".panel", { autoAlpha: 0, duration: 0.4 });
|
||||
```
|
||||
|
||||
`autoAlpha: 0` sets both `opacity: 0` and `visibility: hidden`, which removes the element from hit-testing and accessibility tree at zero alpha — closer to "gone" than plain `opacity: 0`.
|
||||
|
||||
## clearProps
|
||||
|
||||
Removes inline styles set by GSAP when the tween completes:
|
||||
|
||||
```javascript
|
||||
gsap.to(".item", { x: 100, rotation: 45, clearProps: "all" });
|
||||
gsap.to(".item", { x: 100, rotation: 45, clearProps: "rotation,x" });
|
||||
```
|
||||
|
||||
Useful at the end of an animation segment to hand the element back to CSS.
|
||||
|
||||
## CSS Variables
|
||||
|
||||
```javascript
|
||||
gsap.to(".chart", { "--hue": 180, duration: 1 });
|
||||
```
|
||||
|
||||
Animate any custom property. Works for color, length, number — anything CSS will interpolate.
|
||||
|
||||
## Relative and Directional Values
|
||||
|
||||
- Relative: `"+=20"`, `"-=10"`, `"*=2"`.
|
||||
- Directional rotation: `"360_cw"`, `"-170_short"`, `"90_ccw"` — controls which way the angle takes when going between two values.
|
||||
|
||||
## SVG Specifics
|
||||
|
||||
- `svgOrigin` sets transform origin in the SVG's global coordinate space (not the element's local box). **Do not** combine `svgOrigin` with `transformOrigin` on the same element — pick one.
|
||||
- Animate SVG transform attributes via the same alias names (`x`, `y`, `rotation`) — GSAP handles the SVG-specific quirks.
|
||||
|
||||
## Performance Rules
|
||||
|
||||
### Animate transforms, not layout properties
|
||||
|
||||
Animate `x`, `y`, `scale`, `rotation`, `opacity`. Never animate `left`, `right`, `top`, `bottom`, `width`, `height`, `margin*`, the text-reflow props `letterSpacing` / `wordSpacing` / `fontSize` — and never `roundProps`.
|
||||
|
||||
This is a **render-correctness** rule in HyperFrames, not just a GPU-performance nicety. The renderer seeks frame-by-frame and screenshots each frame, and the browser compositor snaps layout properties to whole device pixels. On a fast tween the per-frame step is several pixels, so the snap is invisible; on a slow tween or a long ease-out tail the value moves less than a pixel per frame — it holds the same pixel for several frames, then jumps a whole one. The result is motion that looks smooth when fast but visibly stutters when slow. Transforms interpolate sub-pixel and stay smooth at any speed. `roundProps` forces the same integer snap onto a transform — don't use it.
|
||||
|
||||
"Layout property" is broader than position: anything that triggers **reflow** snaps the same way. `letterSpacing` / `fontSize` are the common trap — a slow "settle" that crawls one of them by a fraction of a pixel per frame dwells on a handful of discrete glyph layouts (visible micro-stutter). The faithful smooth fix depends on which property — **do not reach for `scale` reflexively**:
|
||||
|
||||
- **`fontSize`** → animate `scale`. Scaling text up/down is the same visual and stays sub-pixel smooth (no reflow).
|
||||
- **`letterSpacing` / `wordSpacing`** → uniform `scale` is **not** the same effect (it resizes the glyphs; it does not change the gaps between them). To animate spacing smoothly, split the text into per-character (or per-word) elements and animate each one's `x` — the glyph spread is a transform, sub-pixel smooth and visually identical to a letter-spacing tween. GSAP's `SplitText` does the split. If the spacing change is a minor flourish, hold the final value statically instead.
|
||||
|
||||
Unlike positional props, reflow props snap during browser **layout** — upstream of the canvas raster — so they stutter even in html-in-canvas, and the exception below does **not** apply to them.
|
||||
|
||||
#### Fixing a flagged animation — preserve the intent
|
||||
|
||||
The lint rule tells you a property will stutter; it does **not** tell you the fix, and a fix that merely passes lint can silently change the look. Swapping a `letterSpacing` tighten for a uniform `scale` lints clean but animates a _different thing_ (it resizes the glyphs instead of closing the gaps). Two rules:
|
||||
|
||||
1. **Reproduce the same visual** — same start/end state, same trajectory, only sub-pixel-smooth. Use the faithful equivalent (per-glyph `x` for spacing, `scale` for `fontSize`, `x`/`y` for position), not whichever transform is the least code.
|
||||
2. **Verify against the original, not against the linter.** Render the original and the fixed version and compare the motion at its key moments — the fix should differ only by the removed stutter, not by _where things end up_. Lint-clean-and-smooth is not the bar; faithful-and-smooth is.
|
||||
|
||||
If the faithful fix is non-trivial (a per-glyph split, a measured offset), build it or surface the tradeoff — never downgrade to a cheaper, different effect just to satisfy the linter.
|
||||
|
||||
**Convert a position animation to a transform** by leaving the element at its resting `left`/`top` in CSS and animating the _offset_ with `x`/`y`:
|
||||
|
||||
```javascript
|
||||
// CSS: #card { left: 1340px; top: 540px } ← resting position stays in CSS
|
||||
tl.to("#card", { left: 1340, top: 540, duration: 1 }); // ✗ stutters
|
||||
tl.fromTo("#card", { x: 640, y: 0 }, { x: 0, y: 0, duration: 1 }); // ✓ x/y = delta from CSS rest (640 = startLeft − 1340)
|
||||
```
|
||||
|
||||
For a parent-relative `left: "100%"` sweep, use `xPercent: 100` only when the element is the full width of its container; otherwise convert to pixels (`x: containerWidth`).
|
||||
|
||||
**The one exception:** elements drawn through the html-in-canvas API — those under a `<canvas layoutsubtree>` ancestor, e.g. the `liquid-glass-*` blocks. The canvas rasterizes from sub-pixel `getComputedStyle`, so layout props don't snap there and those elements keep `left`/`top`. Everything the browser lays out (plain DOM) follows the rule.
|
||||
|
||||
The `gsap_non_transform_motion` lint rule is the backstop, not the teacher — reach for transforms from the start instead of animating layout props and waiting for lint to reject them.
|
||||
|
||||
### will-change (sparingly)
|
||||
|
||||
```css
|
||||
.title {
|
||||
will-change: transform;
|
||||
}
|
||||
```
|
||||
|
||||
Only on elements that _actually_ animate. Applied everywhere it becomes useless and burns memory.
|
||||
|
||||
### gsap.quickTo for frequent updates (preview-only)
|
||||
|
||||
For high-frequency updates driven by **events** — pointer move, scroll, audio scrub — `quickTo` reuses the same tween instead of creating a new one each frame:
|
||||
|
||||
```javascript
|
||||
const xTo = gsap.quickTo("#cursor", "x", { duration: 0.4, ease: "power3" });
|
||||
const yTo = gsap.quickTo("#cursor", "y", { duration: 0.4, ease: "power3" });
|
||||
|
||||
container.addEventListener("mousemove", (e) => {
|
||||
xTo(e.pageX);
|
||||
yTo(e.pageY);
|
||||
});
|
||||
```
|
||||
|
||||
> **Render mode has no input events.** The renderer seeks frame-by-frame; `mousemove`, `scroll`, etc. never fire. `quickTo`'s main use case applies in **live preview** in the browser only. For audio-reactive motion in renders, pre-extract audio data and drive the timeline declaratively (see `../rules/gsap-effects.md`).
|
||||
|
||||
### Stagger beats N tweens
|
||||
|
||||
One tween with `stagger` beats N tweens with manual delays for both readability and runtime cost.
|
||||
|
||||
### Cleanup
|
||||
|
||||
In live preview, pause or `kill()` off-screen animations. Render mode is unaffected (the renderer drives time directly).
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
name: hyperframes-gsap-adapter
|
||||
description: GSAP animation API reference for HyperFrames. Use when writing seekable GSAP timelines in HyperFrames compositions, including gsap.to(), from(), fromTo(), set(), timeline position parameters, labels, easing, stagger, finite repeats, and transform performance.
|
||||
---
|
||||
|
||||
# HyperFrames GSAP
|
||||
|
||||
GSAP usage scoped to HyperFrames' seek-driven render model. This skill is the GSAP reference _as constrained by HyperFrames_ — for the framework's broader composition contract see `hyperframes-core`.
|
||||
|
||||
## HyperFrames Contract
|
||||
|
||||
HyperFrames controls GSAP through its `gsap` runtime adapter. Create a paused timeline synchronously, register it on `window.__timelines` with the exact `data-composition-id`, and let HyperFrames seek it.
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
tl.from(".title", { y: 48, opacity: 0, duration: 0.6, ease: "power3.out" }, 0);
|
||||
tl.to(".accent", { scaleX: 1, duration: 0.5, ease: "power2.out" }, 0.25);
|
||||
|
||||
window.__timelines["main"] = tl; // key must equal data-composition-id on the composition root
|
||||
</script>
|
||||
```
|
||||
|
||||
- The registry key must match the composition root's `data-composition-id`.
|
||||
- Bracket and dot syntax both register: `window.__timelines["main"] = tl` and `window.__timelines.main = tl` are equivalent (the linter recognizes both). Bracket form is required when the id isn't a valid identifier (e.g. contains `-`).
|
||||
- Do not call `tl.play()` for render-critical motion.
|
||||
- Do not build timelines inside async code, timers, or event handlers.
|
||||
- Keep loops finite. HyperFrames renders finite video durations.
|
||||
- **Render duration comes from `data-duration` on the composition root, not from GSAP timeline length.** Do not pad the timeline with empty tweens like `tl.set({}, {}, 283)` to "extend" it. (Some external docs show this trick; in HyperFrames it conflicts with the seek-driven duration model — set `data-duration` instead.)
|
||||
|
||||
## Core Tween Methods
|
||||
|
||||
- **gsap.to(targets, vars)** — animate from current state to `vars`. Most common.
|
||||
- **gsap.from(targets, vars)** — animate from `vars` to current state (entrances).
|
||||
- **gsap.fromTo(targets, fromVars, toVars)** — explicit start and end.
|
||||
- **gsap.set(targets, vars)** — apply immediately (duration 0).
|
||||
|
||||
Always use **camelCase** property names (e.g. `backgroundColor`, `rotationX`).
|
||||
|
||||
## Common vars (cheatsheet)
|
||||
|
||||
- **duration** — seconds (default 0.5).
|
||||
- **delay** — seconds before start.
|
||||
- **ease** — `"power1.out"` (default), `"power3.inOut"`, `"back.out(1.7)"`, `"elastic.out(1, 0.3)"`, `"none"`. See `./gsap-easing-and-stagger.md`.
|
||||
- **stagger** — number or object. See `./gsap-easing-and-stagger.md`.
|
||||
- **repeat** — finite number; never `-1` in HyperFrames. Compute repeats from the visible duration.
|
||||
- **yoyo** — alternates direction with repeat.
|
||||
- **overwrite** — `false` (default), `true`, or `"auto"`.
|
||||
- **immediateRender** — default `true` for from()/fromTo(). Set `false` on later tweens targeting the same property+element.
|
||||
- **onComplete**, **onStart**, **onUpdate** — callbacks.
|
||||
|
||||
For transforms, autoAlpha, clearProps, and SVG specifics see `./gsap-transforms-and-perf.md`.
|
||||
|
||||
## Animated Property Allowlist
|
||||
|
||||
HyperFrames is stricter than vanilla GSAP. Animate only:
|
||||
|
||||
- **Compositor-cheap**: `opacity`, `x`, `y`, `scale`, `scaleX`, `scaleY`, `rotation`, `rotationX`, `rotationY`, `skewX`, `skewY`, `transformOrigin`
|
||||
- **Visual fills**: `color`, `backgroundColor`, `borderColor`, `borderRadius`
|
||||
- **CSS variables**: `"--hue": 180` etc.
|
||||
- **Media `volume`** (on `<audio>` / `<video>`): animate for fades/ducking, e.g. `tl.to("#bgm", { volume: 0, duration: 1 }, "outro")`. The runtime probes these keyframes from the timeline and drives them in both preview and render (they match). This sets the _author_ volume; `data-volume` is the static baseline when no tween touches the element.
|
||||
- **DOM text `innerText`** (for numeric counters): tween it directly, e.g. `tl.to(el, { innerText: 100, snap: { innerText: 1 } })` — `snap` keeps it integer; the GSAP inspector recognizes it as a counter. Equivalent to the `onUpdate`-proxy form in `../rules/counting-dynamic-scale.md`; prefer that proxy form when you must also drive font-size, locale formatting (`toLocaleString`), or a suffix in the same tween.
|
||||
|
||||
**Avoid** (use the transform alias instead):
|
||||
|
||||
- `width` / `height` / `top` / `left` / `right` / `bottom` / `margin*` / `padding*` — trigger layout reflows. Use `scaleX/Y` (with `transformOrigin`) or `x` / `y`.
|
||||
|
||||
**Forbidden** (breaks the renderer or the clip lifecycle):
|
||||
|
||||
- `display`, `visibility` — never tween these directly. Use `autoAlpha` (sets opacity + visibility together at endpoints, doesn't tween the discrete property).
|
||||
- Anything driven by `Math.random()`, `Date.now()`, `performance.now()`, or event handlers — animation state must be deterministic from time alone.
|
||||
|
||||
> **Note**: `docs/guides/gsap-animation.mdx` lists `width`/`height`/`visibility` in its "Supported Properties" — that list is too permissive for HyperFrames composition rules. This allowlist is the canonical one. See `hyperframes-core/references/determinism-rules.md` for the full deterministic-render contract.
|
||||
|
||||
## References
|
||||
|
||||
- `./gsap-timeline-and-labels.md` — timeline creation, position parameter (`+=`, `<`, `>`), labels, nesting, sub-comp `fromTo` preference, playback control.
|
||||
- `./gsap-easing-and-stagger.md` — easing families, stagger objects, function-based values, `gsap.matchMedia()`, `gsap.defaults()`.
|
||||
- `./gsap-transforms-and-perf.md` — transform aliases, autoAlpha, `quickTo`, `will-change`, performance rules.
|
||||
- `../rules/gsap-effects.md` — drop-in recipes: typewriter (with cursor / backspace / word rotation) + audio visualizer (uses `skills/hyperframes-creative/scripts/extract-audio-data.py`).
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Use camelCase property names; prefer transform aliases and autoAlpha.
|
||||
- Prefer timelines over chained tweens with delays; use the position parameter.
|
||||
- Add labels with `addLabel()` for readable sequencing.
|
||||
- Pass defaults into the timeline constructor.
|
||||
- Store the tween/timeline return value when controlling playback.
|
||||
|
||||
## Do Not
|
||||
|
||||
- Animate layout properties (`width`/`height`/`top`/`left`) when transforms suffice.
|
||||
- Use both `svgOrigin` and `transformOrigin` on the same SVG element.
|
||||
- Chain animations with `delay` when a timeline can sequence them.
|
||||
- Create tweens before the DOM exists.
|
||||
- Use infinite `repeat: -1` in HyperFrames compositions — use finite repeat counts computed from the visible duration.
|
||||
|
||||
## Credits And References
|
||||
|
||||
- HyperFrames adapter source: `packages/core/src/runtime/adapters/gsap.ts`.
|
||||
- GSAP documentation: https://gsap.com/docs/v3/
|
||||
- GSAP timeline pause and seek behavior: https://gsap.com/docs/v3/GSAP/Timeline/pause%28%29/
|
||||
@@ -0,0 +1,507 @@
|
||||
# HTML-in-Canvas Patterns
|
||||
|
||||
HyperFrames' most powerful visual capability. Capture ANY live HTML/CSS as a GPU texture, then render it through WebGL shaders, Three.js 3D scenes, or post-processing effects — at 60fps, pixel-perfect, with every CSS feature supported.
|
||||
|
||||
**Read this file when a beat deserves cinematic treatment beyond flat GSAP animations.** Use for 1-3 hero beats per video, not every beat. The rest can use standard GSAP — the contrast between flat beats and HTML-in-Canvas beats IS part of the visual storytelling.
|
||||
|
||||
---
|
||||
|
||||
## Core Boilerplate (same in every HTML-in-Canvas composition)
|
||||
|
||||
Every HTML-in-Canvas effect shares this structure. Learn this once, adapt it for any effect.
|
||||
|
||||
```html
|
||||
<!-- 1. Source HTML — your content goes inside a layoutsubtree canvas -->
|
||||
<canvas
|
||||
id="hic-source"
|
||||
layoutsubtree
|
||||
width="1920"
|
||||
height="1080"
|
||||
style="position:absolute;inset:0;opacity:0;"
|
||||
>
|
||||
<div id="hic-content" style="width:1920px;height:1080px;">
|
||||
<!-- YOUR HTML CONTENT HERE — text, images, cards, dashboards, anything -->
|
||||
</div>
|
||||
</canvas>
|
||||
|
||||
<!-- 2. Render target — the visible canvas that shows the effect -->
|
||||
<canvas id="hic-output" width="1920" height="1080" style="position:absolute;inset:0;"></canvas>
|
||||
```
|
||||
|
||||
```js
|
||||
// 3. Feature detection — always check, always provide fallback
|
||||
function isHiCSupported() {
|
||||
var tc = document.createElement("canvas");
|
||||
if (!("layoutSubtree" in tc)) return false;
|
||||
tc.setAttribute("layoutsubtree", "");
|
||||
var ctx = tc.getContext("2d");
|
||||
return ctx && typeof ctx.drawElementImage === "function";
|
||||
}
|
||||
var apiOk = isHiCSupported();
|
||||
|
||||
// 4. Capture function — call this every frame in onUpdate
|
||||
var capCanvas = document.getElementById("hic-source");
|
||||
var capCtx = capCanvas.getContext("2d");
|
||||
function captureContent() {
|
||||
if (apiOk) {
|
||||
capCtx.drawElementImage(document.getElementById("hic-content"), 0, 0, 1920, 1080);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Drive from GSAP timeline — capture + render every frame
|
||||
tl.to(
|
||||
proxy,
|
||||
{
|
||||
/* your animation properties */
|
||||
duration: BEAT_DURATION,
|
||||
ease: "sine.inOut",
|
||||
onUpdate: function () {
|
||||
captureContent();
|
||||
// render your effect here (Three.js or WebGL2)
|
||||
},
|
||||
},
|
||||
0,
|
||||
);
|
||||
```
|
||||
|
||||
**Fallback:** When `drawElementImage` is not available (preview without Chrome flag), draw a solid-color placeholder or use Canvas 2D text. The HyperFrames renderer auto-enables the flag — the effect WILL work in the final video. See the liquid-glass block for a complete fallback example.
|
||||
|
||||
---
|
||||
|
||||
## Effect Catalog
|
||||
|
||||
### 1. 3D Rotation with Bloom (Three.js)
|
||||
|
||||
**What it looks like:** Content floats in 3D space, slowly rotating with cinematic glow around bright edges. Like a product screenshot displayed in a dark theater.
|
||||
|
||||
**When to use:** Hero product showcase, feature reveal, CTA with premium feel.
|
||||
|
||||
**Key Three.js components:** `PlaneGeometry` + `CanvasTexture` + `EffectComposer` + `UnrealBloomPass`
|
||||
|
||||
```js
|
||||
// After the boilerplate above, add:
|
||||
var scene3d = new THREE.Scene();
|
||||
var camera = new THREE.PerspectiveCamera(45, 1920 / 1080, 0.1, 100);
|
||||
camera.position.set(0, 0, 4);
|
||||
|
||||
var renderer = new THREE.WebGLRenderer({
|
||||
canvas: document.getElementById("hic-output"),
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
});
|
||||
renderer.setSize(1920, 1080);
|
||||
|
||||
var texture = new THREE.CanvasTexture(capCanvas);
|
||||
var mesh = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(3.6, 2.2),
|
||||
new THREE.MeshBasicMaterial({ map: texture }),
|
||||
);
|
||||
scene3d.add(mesh);
|
||||
|
||||
// Post-processing: bloom for cinematic glow.
|
||||
// EffectComposer / RenderPass / UnrealBloomPass are ES-module named imports
|
||||
// (see the import block below) — they're NOT properties of THREE in modern
|
||||
// versions. Three.js r150+ removed the UMD `examples/js/` globals.
|
||||
var composer = new EffectComposer(renderer);
|
||||
composer.addPass(new RenderPass(scene3d, camera));
|
||||
composer.addPass(new UnrealBloomPass(new THREE.Vector2(1920, 1080), 0.3, 0.4, 0.85));
|
||||
|
||||
var proxy = { rotY: -0.12, zoom: 4.2 };
|
||||
tl.to(
|
||||
proxy,
|
||||
{
|
||||
rotY: 0.12,
|
||||
zoom: 3.6,
|
||||
duration: BEAT_DURATION,
|
||||
ease: "sine.inOut",
|
||||
onUpdate: function () {
|
||||
captureContent();
|
||||
texture.needsUpdate = true;
|
||||
mesh.rotation.y = proxy.rotY;
|
||||
camera.position.z = proxy.zoom;
|
||||
composer.render();
|
||||
},
|
||||
},
|
||||
0,
|
||||
);
|
||||
```
|
||||
|
||||
**Load Three.js and post-processing via ESM (use a `type="module"` script):**
|
||||
|
||||
```html
|
||||
<script type="module">
|
||||
import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.181.2/+esm";
|
||||
import { EffectComposer } from "https://cdn.jsdelivr.net/npm/three@0.181.2/examples/jsm/postprocessing/EffectComposer.js";
|
||||
import { RenderPass } from "https://cdn.jsdelivr.net/npm/three@0.181.2/examples/jsm/postprocessing/RenderPass.js";
|
||||
import { ShaderPass } from "https://cdn.jsdelivr.net/npm/three@0.181.2/examples/jsm/postprocessing/ShaderPass.js";
|
||||
import { UnrealBloomPass } from "https://cdn.jsdelivr.net/npm/three@0.181.2/examples/jsm/postprocessing/UnrealBloomPass.js";
|
||||
// ... rest of composition code using these imports
|
||||
</script>
|
||||
```
|
||||
|
||||
The `examples/js/` path was removed in Three.js r152. Use `examples/jsm/` (ES modules) with `three@0.181.2` — the version used by the HyperFrames Three.js adapter.
|
||||
|
||||
---
|
||||
|
||||
### 2. Magnetic Cursor Distortion (Raw WebGL2)
|
||||
|
||||
**What it looks like:** Content warps and bends toward a moving point, like a magnet pulling on pixels. Chromatic aberration splits RGB channels at the distortion site.
|
||||
|
||||
**When to use:** Interactive feel, product demo with cursor, "look at THIS feature" moment.
|
||||
|
||||
**Key technique:** Custom fragment shader with Gaussian warp + chromatic split. No Three.js needed — just raw WebGL2.
|
||||
|
||||
```js
|
||||
// WebGL2 setup
|
||||
var gl = document.getElementById("hic-output").getContext("webgl2", {
|
||||
alpha: false,
|
||||
preserveDrawingBuffer: true,
|
||||
});
|
||||
|
||||
// Vertex shader — full-screen quad
|
||||
var VS = `#version 300 es
|
||||
in vec2 a_pos;
|
||||
out vec2 v_uv;
|
||||
void main() {
|
||||
v_uv = a_pos * 0.5 + 0.5;
|
||||
gl_Position = vec4(a_pos, 0.0, 1.0);
|
||||
}`;
|
||||
|
||||
// Fragment shader — magnetic warp + chromatic aberration
|
||||
var FS = `#version 300 es
|
||||
precision highp float;
|
||||
in vec2 v_uv;
|
||||
out vec4 fragColor;
|
||||
uniform sampler2D u_tex;
|
||||
uniform vec2 u_cursor; // cursor position (0-1)
|
||||
uniform float u_strength; // warp strength (0-1)
|
||||
|
||||
void main() {
|
||||
vec2 uv = v_uv;
|
||||
vec2 delta = uv - u_cursor;
|
||||
float dist = length(delta);
|
||||
float warp = u_strength * exp(-dist * dist * 8.0);
|
||||
vec2 warped = uv - delta * warp * 0.3;
|
||||
|
||||
// Chromatic aberration at distortion site
|
||||
float aberration = warp * 0.008;
|
||||
float r = texture(u_tex, warped + vec2(aberration, 0.0)).r;
|
||||
float g = texture(u_tex, warped).g;
|
||||
float b = texture(u_tex, warped - vec2(aberration, 0.0)).b;
|
||||
fragColor = vec4(r, g, b, 1.0);
|
||||
}`;
|
||||
|
||||
// Compile, link, setup quad geometry, upload texture...
|
||||
// (See registry/blocks/vfx-magnetic/vfx-magnetic.html for complete implementation)
|
||||
|
||||
// Drive cursor position from GSAP
|
||||
var proxy = { cx: 0.2, cy: 0.5, strength: 0.0 };
|
||||
tl.to(
|
||||
proxy,
|
||||
{
|
||||
cx: 0.8,
|
||||
cy: 0.4,
|
||||
strength: 1.0,
|
||||
duration: BEAT_DURATION,
|
||||
ease: "power2.inOut",
|
||||
onUpdate: function () {
|
||||
captureContent();
|
||||
// Upload texture, set uniforms, draw
|
||||
gl.uniform2f(cursorLoc, proxy.cx, proxy.cy);
|
||||
gl.uniform1f(strengthLoc, proxy.strength);
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||
},
|
||||
},
|
||||
0,
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Shatter / Fragment Explosion (Three.js)
|
||||
|
||||
**What it looks like:** Content breaks into geometric fragments that fly apart, revealing what's behind.
|
||||
|
||||
**When to use:** Dramatic transition, "breaking free" moment, tension release.
|
||||
|
||||
**Key technique:** Subdivide the source texture into triangle mesh fragments using BufferGeometry, then animate each fragment's position/rotation with GSAP.
|
||||
|
||||
Study `registry/blocks/vfx-shatter/vfx-shatter.html` for the complete 1156-line implementation. The core idea:
|
||||
|
||||
```js
|
||||
// 1. Capture content to texture (same boilerplate)
|
||||
// Seeded PRNG for determinism — Math.random() is banned
|
||||
function mulberry32(seed) {
|
||||
return function () {
|
||||
seed |= 0;
|
||||
seed = (seed + 0x6d2b79f5) | 0;
|
||||
var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), 61 | t);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
var rng = mulberry32(42);
|
||||
|
||||
// 2. Create N triangle fragments from the texture
|
||||
var fragments = [];
|
||||
for (var i = 0; i < NUM_FRAGMENTS; i++) {
|
||||
var geom = new THREE.BufferGeometry();
|
||||
var mesh = new THREE.Mesh(geom, new THREE.MeshBasicMaterial({ map: texture }));
|
||||
scene3d.add(mesh);
|
||||
fragments.push({ mesh: mesh, targetPos: randomExplosionVector(rng), delay: rng() * 0.5 });
|
||||
}
|
||||
|
||||
// 3. Animate: first hold still, then EXPLODE
|
||||
tl.to({}, { duration: holdTime }, 0);
|
||||
fragments.forEach(function (frag) {
|
||||
tl.to(
|
||||
frag.mesh.position,
|
||||
{
|
||||
x: frag.targetPos.x,
|
||||
y: frag.targetPos.y,
|
||||
z: frag.targetPos.z,
|
||||
duration: 0.8,
|
||||
ease: "power3.in",
|
||||
},
|
||||
holdTime + frag.delay,
|
||||
);
|
||||
tl.to(
|
||||
frag.mesh.rotation,
|
||||
{ x: rng() * 4, y: rng() * 4, duration: 0.8, ease: "power2.in" },
|
||||
holdTime + frag.delay,
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Liquid / Fluid Surface (Three.js)
|
||||
|
||||
**What it looks like:** Content floats above a rippling liquid surface with real-time wave dynamics. Or content IS the surface, undulating like water.
|
||||
|
||||
**When to use:** Organic/premium feel, ambient background, "living" product showcase.
|
||||
|
||||
**Key technique:** Subdivided PlaneGeometry with vertex displacement driven by noise functions in a vertex shader.
|
||||
|
||||
Study `registry/blocks/vfx-liquid-background/vfx-liquid-background.html` for the 1244-line implementation. Core idea:
|
||||
|
||||
```js
|
||||
// Custom vertex shader with wave displacement
|
||||
var vertexShader = `
|
||||
varying vec2 vUv;
|
||||
uniform float u_time;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
vec3 pos = position;
|
||||
// Sine wave displacement
|
||||
pos.z += sin(pos.x * 3.0 + u_time * 2.0) * 0.15;
|
||||
pos.z += cos(pos.y * 2.5 + u_time * 1.5) * 0.1;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
var mesh = new THREE.Mesh(
|
||||
new THREE.PlaneGeometry(4, 3, 64, 64), // heavily subdivided for smooth waves
|
||||
new THREE.ShaderMaterial({
|
||||
vertexShader: vertexShader,
|
||||
fragmentShader: `varying vec2 vUv; uniform sampler2D u_tex;
|
||||
void main() { gl_FragColor = texture2D(u_tex, vUv); }`,
|
||||
uniforms: {
|
||||
u_tex: { value: texture },
|
||||
u_time: { value: 0 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Portal / Dimensional Reveal (Three.js)
|
||||
|
||||
**What it looks like:** A glowing circular portal opens and content emerges through it from another dimension.
|
||||
|
||||
**When to use:** Product reveal, "entering the app" moment, hero feature introduction.
|
||||
|
||||
Study `registry/blocks/vfx-portal/vfx-portal.html` for the complete 863-line implementation.
|
||||
|
||||
---
|
||||
|
||||
## When to Use HTML-in-Canvas vs Standard GSAP
|
||||
|
||||
| Scenario | Use | Why |
|
||||
| -------------------------------- | ------------------------------------ | ------------------------------------ |
|
||||
| Hero product screenshot showcase | HTML-in-Canvas (3D rotation + bloom) | Makes flat UI feel cinematic |
|
||||
| Feature list / stats | Standard GSAP | Content-focused, doesn't need 3D |
|
||||
| CTA / brand reveal | HTML-in-Canvas (portal or magnetic) | Makes the moment memorable |
|
||||
| Social proof / logos | Standard GSAP | Orderly cascade, trust is steady |
|
||||
| Transition between acts | HTML-in-Canvas (shatter) | Dramatic act break |
|
||||
| Background atmosphere | HTML-in-Canvas (liquid surface) | Premium ambient feel |
|
||||
| Quick feature cards | Standard GSAP | Speed matters, 3D would slow it down |
|
||||
|
||||
---
|
||||
|
||||
## More Effects You Can Build
|
||||
|
||||
These aren't in the VFX blocks — build them yourself from the core boilerplate + a custom fragment shader. Each effect is a single GLSL function applied to the captured texture.
|
||||
|
||||
### 6. Noise Dissolve
|
||||
|
||||
Content dissolves into noise particles, revealing what's behind. Great for transitions.
|
||||
|
||||
```glsl
|
||||
// Fragment shader — noise-based dissolve
|
||||
uniform float u_progress; // 0.0 = fully visible, 1.0 = fully dissolved
|
||||
uniform sampler2D u_tex;
|
||||
|
||||
float hash(vec2 p) {
|
||||
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = v_uv;
|
||||
float noise = hash(uv * 50.0);
|
||||
float threshold = u_progress;
|
||||
if (noise < threshold) {
|
||||
// Edge glow at the dissolve boundary
|
||||
float edge = smoothstep(threshold - 0.05, threshold, noise);
|
||||
fragColor = vec4(1.0, 0.6, 0.2, 1.0) * (1.0 - edge); // orange edge glow
|
||||
} else {
|
||||
fragColor = texture(u_tex, uv);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Holographic / Iridescent
|
||||
|
||||
Content gets a rainbow-shifting holographic sheen that moves with time. Premium, futuristic feel.
|
||||
|
||||
```glsl
|
||||
uniform float u_time;
|
||||
uniform sampler2D u_tex;
|
||||
|
||||
void main() {
|
||||
vec4 color = texture(u_tex, v_uv);
|
||||
// Iridescent color shift based on position + time
|
||||
float angle = v_uv.x * 6.28 + v_uv.y * 3.14 + u_time * 0.5;
|
||||
vec3 holo = vec3(
|
||||
sin(angle) * 0.5 + 0.5,
|
||||
sin(angle + 2.094) * 0.5 + 0.5,
|
||||
sin(angle + 4.189) * 0.5 + 0.5
|
||||
);
|
||||
// Blend holographic over content (subtle overlay)
|
||||
fragColor = vec4(mix(color.rgb, holo, 0.15 + 0.1 * sin(u_time)), color.a);
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Scan Lines + CRT
|
||||
|
||||
Retro CRT monitor look — scan lines, slight curvature, phosphor glow. Great for "code" or "terminal" beats.
|
||||
|
||||
```glsl
|
||||
uniform sampler2D u_tex;
|
||||
uniform float u_time;
|
||||
|
||||
void main() {
|
||||
vec2 uv = v_uv;
|
||||
// Barrel distortion (CRT curvature)
|
||||
vec2 centered = uv - 0.5;
|
||||
float dist = dot(centered, centered);
|
||||
uv = uv + centered * dist * 0.15;
|
||||
|
||||
vec4 color = texture(u_tex, uv);
|
||||
// Scan lines
|
||||
float scanline = sin(uv.y * 800.0) * 0.04;
|
||||
color.rgb -= scanline;
|
||||
// Slight RGB offset (phosphor)
|
||||
color.r = texture(u_tex, uv + vec2(0.001, 0.0)).r;
|
||||
color.b = texture(u_tex, uv - vec2(0.001, 0.0)).b;
|
||||
// Vignette
|
||||
float vignette = 1.0 - dist * 2.0;
|
||||
fragColor = vec4(color.rgb * vignette, 1.0);
|
||||
}
|
||||
```
|
||||
|
||||
### 9. Frosted Glass Blur
|
||||
|
||||
Content behind frosted glass — visible but softened, with subtle light refraction. Good for "behind the scenes" or "coming soon" moments.
|
||||
|
||||
```glsl
|
||||
uniform sampler2D u_tex;
|
||||
uniform float u_blur; // 0.0 = clear, 1.0 = full frost
|
||||
|
||||
void main() {
|
||||
vec2 uv = v_uv;
|
||||
vec4 color = vec4(0.0);
|
||||
// Box blur with offset
|
||||
float radius = u_blur * 0.015;
|
||||
for (float x = -2.0; x <= 2.0; x += 1.0) {
|
||||
for (float y = -2.0; y <= 2.0; y += 1.0) {
|
||||
color += texture(u_tex, uv + vec2(x, y) * radius);
|
||||
}
|
||||
}
|
||||
color /= 25.0;
|
||||
// Add frost noise texture
|
||||
float frost = fract(sin(dot(uv * 200.0, vec2(12.9898, 78.233))) * 43758.5453);
|
||||
color.rgb += frost * 0.03 * u_blur;
|
||||
fragColor = color;
|
||||
}
|
||||
```
|
||||
|
||||
### 10. Pixel Sort / Glitch Art
|
||||
|
||||
Pixels rearrange themselves in vertical or horizontal strips — digital art aesthetic. Great for tech/creative brands.
|
||||
|
||||
```glsl
|
||||
uniform sampler2D u_tex;
|
||||
uniform float u_intensity; // 0-1
|
||||
|
||||
void main() {
|
||||
vec2 uv = v_uv;
|
||||
// Random horizontal displacement per row
|
||||
float row = floor(uv.y * 80.0);
|
||||
float noise = fract(sin(row * 127.1) * 43758.5);
|
||||
float displace = step(0.7, noise) * u_intensity * 0.1;
|
||||
// Shift UV with RGB split
|
||||
float r = texture(u_tex, uv + vec2(displace, 0.0)).r;
|
||||
float g = texture(u_tex, uv).g;
|
||||
float b = texture(u_tex, uv - vec2(displace * 0.5, 0.0)).b;
|
||||
fragColor = vec4(r, g, b, 1.0);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Creating ANY Custom Effect
|
||||
|
||||
The fragment shaders above are templates. The pattern is always:
|
||||
|
||||
1. **Capture your HTML content** with `drawElementImage` (the boilerplate at the top)
|
||||
2. **Upload the captured canvas as a WebGL texture**
|
||||
3. **Write a fragment shader** that reads from the texture and outputs modified colors
|
||||
4. **Drive shader uniforms from GSAP** via `onUpdate`
|
||||
|
||||
Any GLSL effect from ShaderToy, The Book of Shaders, CodePen, or anywhere else can be adapted:
|
||||
|
||||
1. Find an effect you like (search "GLSL [effect name]" or browse shadertoy.com)
|
||||
2. Copy the fragment shader
|
||||
3. Replace `iResolution` with `vec2(1920.0, 1080.0)`, `iTime` with your `u_time` uniform
|
||||
4. Add `uniform sampler2D u_tex;` for the captured content texture
|
||||
5. Wire the uniforms to GSAP proxy values
|
||||
|
||||
**Geometry ideas beyond flat planes:**
|
||||
|
||||
- `SphereGeometry` — content mapped onto a globe (world map, global reach)
|
||||
- `CylinderGeometry` — content on a rotating cylinder (carousel/scroll feel)
|
||||
- `TorusGeometry` — content wrapped around a ring (infinity, cycle)
|
||||
- `BoxGeometry` — content on a 3D box (product packaging, dice)
|
||||
- GLTF models — content mapped as screen texture on phone, laptop, monitor (see `vfx-iphone-device`)
|
||||
|
||||
**Post-processing stacking** (Three.js EffectComposer):
|
||||
|
||||
- Bloom + film grain = cinematic
|
||||
- Bloom + chromatic aberration = lens effect
|
||||
- Depth of field + vignette = focused attention
|
||||
- Film grain + scan lines = retro
|
||||
- Multiple passes stack — add as many as you want
|
||||
|
||||
**You are not limited to the effects listed here.** If you can imagine a visual treatment, you can build it. The HTML-in-Canvas API gives you the source material (any HTML rendered as a texture), and WebGL/Three.js gives you unlimited creative control over how that material is presented.
|
||||
@@ -0,0 +1,117 @@
|
||||
---
|
||||
name: hyperframes-lottie
|
||||
description: Lottie and dotLottie adapter patterns for HyperFrames. Use when embedding lottie-web JSON animations, .lottie files, @lottiefiles/dotlottie-web players, registering instances on window.__hfLottie, or making After Effects exports deterministic in HyperFrames.
|
||||
---
|
||||
|
||||
# Lottie for HyperFrames
|
||||
|
||||
HyperFrames can seek both `lottie-web` and dotLottie players through its `lottie` runtime adapter. Lottie is a strong fit because the animation timeline is already encoded in the asset; HyperFrames only needs a player object it can seek.
|
||||
|
||||
## Contract
|
||||
|
||||
- Load assets from local project files, usually under `assets/`.
|
||||
- Set `autoplay: false`.
|
||||
- Prefer `loop: false` unless the user explicitly wants a loop.
|
||||
- Register every returned animation or player on `window.__hfLottie`.
|
||||
- Keep the Lottie container dimensions stable with CSS.
|
||||
|
||||
The adapter seeks `lottie-web` with `goToAndStop(timeMs, false)` and dotLottie with frame or percentage APIs depending on player shape.
|
||||
|
||||
## lottie-web Pattern
|
||||
|
||||
```html
|
||||
<div id="logo-lottie" class="lottie-layer"></div>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/bodymovin/5.12.2/lottie.min.js"></script>
|
||||
<script>
|
||||
const anim = lottie.loadAnimation({
|
||||
container: document.getElementById("logo-lottie"),
|
||||
renderer: "svg",
|
||||
loop: false,
|
||||
autoplay: false,
|
||||
path: "assets/logo-reveal.json",
|
||||
});
|
||||
|
||||
window.__hfLottie = window.__hfLottie || [];
|
||||
window.__hfLottie.push(anim);
|
||||
</script>
|
||||
```
|
||||
|
||||
```css
|
||||
.lottie-layer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
```
|
||||
|
||||
## dotLottie Pattern
|
||||
|
||||
```html
|
||||
<canvas id="product-lottie" class="lottie-canvas"></canvas>
|
||||
<script src="https://unpkg.com/@lottiefiles/dotlottie-web"></script>
|
||||
<script>
|
||||
const player = new DotLottie({
|
||||
canvas: document.getElementById("product-lottie"),
|
||||
src: "assets/product-flow.lottie",
|
||||
autoplay: false,
|
||||
loop: false,
|
||||
});
|
||||
|
||||
window.__hfLottie = window.__hfLottie || [];
|
||||
window.__hfLottie.push(player);
|
||||
</script>
|
||||
```
|
||||
|
||||
```css
|
||||
.lottie-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
```
|
||||
|
||||
## Multiple Animations
|
||||
|
||||
Push each player into the same registry:
|
||||
|
||||
```js
|
||||
window.__hfLottie = window.__hfLottie || [];
|
||||
window.__hfLottie.push(backgroundAnim);
|
||||
window.__hfLottie.push(iconAnim);
|
||||
window.__hfLottie.push(confettiAnim);
|
||||
```
|
||||
|
||||
HyperFrames seeks them all to the same composition time.
|
||||
|
||||
## Composition Duration
|
||||
|
||||
The render engine needs the composition's total length. GSAP timelines report duration automatically; a Lottie-only composition has no timeline object, so the runtime reads the registered animation's native length directly — `totalFrames / frameRate` for `lottie-web`, or the player's own `duration` for dotLottie. `data-duration` on the root element is optional for Lottie compositions: as long as every animation is registered on `window.__hfLottie` (per the contract above), the runtime has a finite duration to work with even when you set `loop: true`.
|
||||
|
||||
## Good Uses
|
||||
|
||||
- After Effects exports that are already known to render correctly in lottie-web.
|
||||
- Logo reveals, icon loops, decorative accents, and product UI motion.
|
||||
- Translating Remotion Lottie usage into plain HyperFrames HTML.
|
||||
|
||||
## Avoid
|
||||
|
||||
- Relying on remote `path` URLs at render time.
|
||||
- Starting playback with `play()`.
|
||||
- Assuming unsupported After Effects effects will survive export. Test the JSON or `.lottie` file in a browser first.
|
||||
- Loading a player asynchronously and registering it after HyperFrames validation has already inspected the page.
|
||||
|
||||
## Validation
|
||||
|
||||
After editing a Lottie composition:
|
||||
|
||||
```bash
|
||||
npx hyperframes lint
|
||||
npx hyperframes check
|
||||
```
|
||||
|
||||
## Credits And References
|
||||
|
||||
- HyperFrames adapter source: `packages/core/src/runtime/adapters/lottie.ts`.
|
||||
- Duration auto-inference: `packages/core/src/runtime/init.ts` (`resolveAdapterDurationFloorSeconds`), `getInferredDurationSeconds` in the adapter above.
|
||||
- lottie-web by Airbnb: https://github.com/airbnb/lottie-web
|
||||
- lottie-web `loadAnimation` options: https://github.com/airbnb/lottie-web/wiki/loadAnimation-options
|
||||
- dotLottie web player methods by LottieFiles: https://developers.lottiefiles.com/docs/dotlottie-player/dotlottie-web/methods
|
||||
@@ -0,0 +1,131 @@
|
||||
---
|
||||
name: hyperframes-three
|
||||
description: Three.js and WebGL adapter patterns for HyperFrames. Use when creating deterministic Three.js scenes, WebGL canvas layers, AnimationMixer timelines, camera motion, shader-driven visuals, or canvas renders that respond to HyperFrames hf-seek events.
|
||||
---
|
||||
|
||||
# Three.js for HyperFrames
|
||||
|
||||
HyperFrames supports Three.js through its `three` runtime adapter. The adapter does not own your scene. It publishes HyperFrames time and dispatches a seek event so your composition can render the exact frame.
|
||||
|
||||
## Contract
|
||||
|
||||
- Create the scene, camera, renderer, materials, and assets synchronously when possible.
|
||||
- Render from HyperFrames time, not wall-clock time.
|
||||
- Listen for the `hf-seek` event and render exactly that time.
|
||||
- Load models, textures, and HDRIs before render-critical seeking. Do not fetch them at seek time.
|
||||
- Avoid `requestAnimationFrame` or `renderer.setAnimationLoop` as the source of truth for render-critical motion.
|
||||
- **Always set `data-duration="<seconds>"` on the root `[data-composition-id]` element.** Unlike CSS/WAAPI/Lottie, the `three` adapter has no duration auto-inference — it only forwards time via `hf-seek`/`__hfThreeTime`, it doesn't inspect your scene for an `AnimationClip`/`AnimationMixer` length. Without `data-duration` (and no GSAP timeline), the render engine has no way to know how long to capture and fails with "Composition has zero duration". `npx hyperframes lint` errors on this (`root_composition_missing_duration_source`).
|
||||
|
||||
The adapter sets `window.__hfThreeTime` and dispatches `new CustomEvent("hf-seek", { detail: { time } })` on each seek.
|
||||
|
||||
## Basic Pattern
|
||||
|
||||
```html
|
||||
<canvas id="three-layer"></canvas>
|
||||
<script type="module">
|
||||
import * as THREE from "https://cdn.jsdelivr.net/npm/three@0.181.2/+esm";
|
||||
|
||||
const canvas = document.getElementById("three-layer");
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true });
|
||||
// Match these to your composition's frame size.
|
||||
renderer.setSize(1920, 1080, false);
|
||||
renderer.setPixelRatio(1);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(35, 1920 / 1080, 0.1, 100);
|
||||
camera.position.set(0, 0, 6);
|
||||
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.IcosahedronGeometry(1.4, 4),
|
||||
new THREE.MeshStandardMaterial({ color: 0x64d2ff, roughness: 0.38 }),
|
||||
);
|
||||
scene.add(mesh);
|
||||
scene.add(new THREE.HemisphereLight(0xffffff, 0x223344, 2));
|
||||
|
||||
function renderAt(time) {
|
||||
mesh.rotation.y = time * 0.7;
|
||||
mesh.rotation.x = Math.sin(time * 0.6) * 0.16;
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
window.addEventListener("hf-seek", (event) => {
|
||||
renderAt(event.detail.time);
|
||||
});
|
||||
|
||||
renderAt(window.__hfThreeTime || 0);
|
||||
</script>
|
||||
```
|
||||
|
||||
```css
|
||||
#three-layer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
```
|
||||
|
||||
## Loading Addons (`GLTFLoader`, `OrbitControls`, etc.)
|
||||
|
||||
For anything under `three/addons/`, use an importmap so bare specifiers resolve. The HyperFrames lint recognizes both this form and the inline `+esm` import above — pick whichever your composition needs.
|
||||
|
||||
```html
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://cdn.jsdelivr.net/npm/three@0.181.2/build/three.module.js",
|
||||
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.181.2/examples/jsm/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from "three";
|
||||
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
|
||||
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
||||
// ...
|
||||
</script>
|
||||
```
|
||||
|
||||
Pin the `three` version in both entries to the same value. Mixing versions across the map and bare imports causes silent breakage.
|
||||
|
||||
## AnimationMixer Pattern
|
||||
|
||||
For GLTF or authored clip animation, seek the mixer directly:
|
||||
|
||||
```js
|
||||
function renderAt(time) {
|
||||
mixer.setTime(time);
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
```
|
||||
|
||||
If several mixers exist, seek all of them from the same `time`.
|
||||
|
||||
## Good Uses
|
||||
|
||||
- Deterministic 3D objects, product spins, particles with seeded data, and shader plates.
|
||||
- Camera moves derived from `time`.
|
||||
- GLTF animation clips when assets are local and loaded before validation completes.
|
||||
|
||||
## Avoid
|
||||
|
||||
- Using `Date.now()`, `performance.now()`, or clock deltas to update scene state.
|
||||
- Leaving render-critical work inside a free-running animation loop.
|
||||
- Loading remote models or textures at render time.
|
||||
- Device-pixel-ratio dependent output. Pin renderer size and pixel ratio for video renders.
|
||||
- Post-processing passes that depend on previous frame history unless you can reconstruct state from time.
|
||||
|
||||
## Validation
|
||||
|
||||
After editing a Three.js composition:
|
||||
|
||||
```bash
|
||||
npx hyperframes lint
|
||||
npx hyperframes check
|
||||
```
|
||||
|
||||
## Credits And References
|
||||
|
||||
- HyperFrames adapter source: `packages/core/src/runtime/adapters/three.ts`.
|
||||
- Why `data-duration` is required here specifically (no auto-inference for this adapter): `packages/core/src/runtime/init.ts` (`resolveAdapterDurationFloorSeconds`) and the CSS/WAAPI/Lottie adapters' `getInferredDurationSeconds`, which the `three` adapter deliberately does not implement.
|
||||
- Three.js `WebGLRenderer` docs: https://threejs.org/docs/pages/WebGLRenderer.html
|
||||
- Three.js `AnimationMixer.setTime()` docs: https://threejs.org/docs/pages/AnimationMixer.html
|
||||
@@ -0,0 +1,178 @@
|
||||
---
|
||||
name: hyperframes-typegpu
|
||||
description: TypeGPU and raw WebGPU adapter patterns for HyperFrames. Use when creating GPU-rendered compositions with TypeGPU, raw WebGPU, WGSL fragment shaders, compute pipelines, liquid glass effects, particle systems, or any canvas layer driven by navigator.gpu that responds to HyperFrames hf-seek events.
|
||||
---
|
||||
|
||||
# TypeGPU / WebGPU for HyperFrames
|
||||
|
||||
HyperFrames supports TypeGPU and raw WebGPU through its `typegpu` runtime adapter. The adapter does not own your pipeline. It publishes HyperFrames time and dispatches a seek event so your composition can render the exact GPU frame.
|
||||
|
||||
## Render-environment prerequisite (WebGPU + html-in-canvas)
|
||||
|
||||
The render engine auto-passes `--enable-unsafe-webgpu` and `--enable-features=CanvasDrawElement` to its Chrome launch args. Stock Chromium and the bundled headless-shell **do not** support WebGPU + `drawElementImage` together — the combo that liquid-glass blocks need (`ios26-liquid-glass`, `macos-tahoe-liquid-glass`, `liquid-glass-*`, `vfx-liquid-glass`). For those blocks, point the engine at Brave (or Chrome canary) by setting `PRODUCER_HEADLESS_SHELL_PATH` to the browser binary before running `npx hyperframes render` / `preview`. Plain TypeGPU layers without HTML-as-texture work in headless-shell — only the html-in-canvas + WebGPU combination needs the override.
|
||||
|
||||
## Contract
|
||||
|
||||
- Initialize WebGPU asynchronously (`await navigator.gpu.requestAdapter()`), but register all GSAP tweens **synchronously** — before any `await`. The HyperFrames player reads the timeline immediately at page load.
|
||||
- Render from HyperFrames time, not `performance.now()`.
|
||||
- Listen for the `hf-seek` event and re-render at exactly that time.
|
||||
- Guard against environments where WebGPU is unavailable — the adapter does not check for you.
|
||||
- For video renders, call `await device.queue.onSubmittedWorkDone()` after submitting GPU work to ensure the canvas is flushed before the frame is captured.
|
||||
|
||||
The adapter sets `window.__hfTypegpuTime` and dispatches `new CustomEvent("hf-seek", { detail: { time } })` on each seek.
|
||||
|
||||
## Basic Pattern
|
||||
|
||||
```html
|
||||
<canvas id="gpu-layer"></canvas>
|
||||
<script>
|
||||
(async () => {
|
||||
if (!navigator.gpu) return;
|
||||
const adapter = await navigator.gpu.requestAdapter();
|
||||
if (!adapter) return;
|
||||
const device = await adapter.requestDevice();
|
||||
const canvas = document.getElementById("gpu-layer");
|
||||
canvas.width = 1920;
|
||||
canvas.height = 1080;
|
||||
const ctx = canvas.getContext("webgpu");
|
||||
const fmt = navigator.gpu.getPreferredCanvasFormat();
|
||||
ctx.configure({ device, format: fmt, alphaMode: "opaque" });
|
||||
|
||||
// Build your pipeline, buffers, bind groups...
|
||||
const timeUniform = new Float32Array([0]);
|
||||
const timeBuf = device.createBuffer({
|
||||
size: 16,
|
||||
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
||||
});
|
||||
|
||||
function render(t) {
|
||||
timeUniform[0] = t;
|
||||
device.queue.writeBuffer(timeBuf, 0, timeUniform);
|
||||
const enc = device.createCommandEncoder();
|
||||
const pass = enc.beginRenderPass({
|
||||
colorAttachments: [
|
||||
{
|
||||
view: ctx.getCurrentTexture().createView(),
|
||||
loadOp: "clear",
|
||||
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
||||
storeOp: "store",
|
||||
},
|
||||
],
|
||||
});
|
||||
pass.setPipeline(pipeline);
|
||||
pass.setBindGroup(0, bindGroup);
|
||||
pass.draw(3);
|
||||
pass.end();
|
||||
device.queue.submit([enc.finish()]);
|
||||
}
|
||||
|
||||
render(0);
|
||||
window.addEventListener("hf-seek", (e) => render(e.detail.time));
|
||||
})();
|
||||
</script>
|
||||
```
|
||||
|
||||
## Timeline Registration
|
||||
|
||||
GSAP tweens that drive text, captions, or HTML elements must be registered **synchronously** — before any `await`:
|
||||
|
||||
```js
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// Caption tweens: synchronous, added before WebGPU init
|
||||
gsap.set(".cap", { opacity: 0 });
|
||||
tl.to("#cap-1", { opacity: 1, duration: 0.3 }, 1.0);
|
||||
tl.to("#cap-1", { opacity: 0, duration: 0.2 }, 3.5);
|
||||
|
||||
window.__timelines["my-comp"] = tl;
|
||||
|
||||
// GPU-dependent tweens can go inside the async IIFE
|
||||
(async () => {
|
||||
// ... WebGPU init ...
|
||||
const proxy = { value: 0 };
|
||||
tl.to(proxy, { value: 1, duration: 2, onUpdate: render }, 0.5);
|
||||
})();
|
||||
```
|
||||
|
||||
## Video-Backed Effects (Liquid Glass, Distortion)
|
||||
|
||||
To use a `<video>` as the GPU input texture:
|
||||
|
||||
```js
|
||||
const videoEl = document.getElementById("aroll");
|
||||
|
||||
// Wait for video metadata before creating the texture
|
||||
await new Promise((r) => {
|
||||
if (videoEl.readyState >= 1) r();
|
||||
else videoEl.addEventListener("loadedmetadata", r, { once: true });
|
||||
});
|
||||
|
||||
// Create texture at the video's NATIVE resolution
|
||||
const vw = videoEl.videoWidth,
|
||||
vh = videoEl.videoHeight;
|
||||
const bgTex = device.createTexture({
|
||||
size: [vw, vh],
|
||||
format: "rgba8unorm",
|
||||
usage:
|
||||
GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
|
||||
});
|
||||
|
||||
function render(t) {
|
||||
try {
|
||||
device.queue.copyExternalImageToTexture({ source: videoEl }, { texture: bgTex }, [vw, vh]);
|
||||
} catch (_) {
|
||||
/* frame not decoded yet */
|
||||
}
|
||||
// ... draw ...
|
||||
}
|
||||
```
|
||||
|
||||
**Render-mode caveat:** headless Chrome may fail `copyExternalImageToTexture` for video elements. For production renders, pre-extract key frames via FFmpeg as PNGs and load them as image textures instead.
|
||||
|
||||
## Frosted Blur via Downsample Pass
|
||||
|
||||
A single-pass Gaussian kernel is too weak for glass-like frosted blur. Use a two-pass approach:
|
||||
|
||||
1. **Pass 1 — Downsample:** render the full-res texture to a small texture (1/6 resolution). Bilinear filtering during the downsample naturally averages pixels.
|
||||
2. **Pass 2 — Glass composite:** sample the small texture for the frosted interior (bilinear upscale = heavy smooth blur) and the full-res texture for sharp areas and chromatic refraction.
|
||||
|
||||
This matches TypeGPU's `textureSampleBias` mip-level approach without generating mipmaps.
|
||||
|
||||
## Transparent vs Opaque Canvas
|
||||
|
||||
- **`alphaMode: 'opaque'`** — the GPU canvas renders the full frame (video + effect). Use when the GPU pipeline handles all visual content.
|
||||
- **`alphaMode: 'premultiplied'`** — the GPU canvas is transparent where alpha = 0, letting HTML elements below show through. Use for overlays (particles, path animations) on top of a regular `<video>` element.
|
||||
|
||||
## WGSL Full-Screen Triangle
|
||||
|
||||
The standard vertex shader for full-screen effects (no vertex buffer needed):
|
||||
|
||||
```wgsl
|
||||
struct Vo { @builtin(position) pos: vec4f, @location(0) uv: vec2f }
|
||||
|
||||
@vertex fn vs(@builtin(vertex_index) vi: u32) -> Vo {
|
||||
let ps = array<vec2f, 3>(vec2f(-1., -1.), vec2f(3., -1.), vec2f(-1., 3.));
|
||||
let ts = array<vec2f, 3>(vec2f(0., 1.), vec2f(2., 1.), vec2f(0., -1.));
|
||||
return Vo(vec4f(ps[vi], 0., 1.), ts[vi]);
|
||||
}
|
||||
```
|
||||
|
||||
Draw with `pass.draw(3)` — one triangle that covers the viewport.
|
||||
|
||||
## Rounded-Rect SDF (Liquid Glass Pill)
|
||||
|
||||
```wgsl
|
||||
fn sdf_box(p: vec2f, half_size: vec2f, corner_radius: f32) -> f32 {
|
||||
let d = abs(p) - half_size + vec2f(corner_radius);
|
||||
return length(max(d, vec2f(0.))) + min(max(d.x, d.y), 0.) - corner_radius;
|
||||
}
|
||||
```
|
||||
|
||||
Use this to define inside/ring/outside zones for glass effects. Negative values are inside the shape.
|
||||
|
||||
## Deterministic Rendering
|
||||
|
||||
- No `Math.random()` — use a seeded PRNG.
|
||||
- No `requestAnimationFrame` for the render loop — render only in response to `hf-seek`.
|
||||
- No `performance.now()` for animation time — read `window.__hfTypegpuTime` or `e.detail.time`.
|
||||
- After GPU submit, call `await device.queue.onSubmittedWorkDone()` for render-mode frame capture.
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
name: hyperframes-waapi
|
||||
description: Web Animations API adapter patterns for HyperFrames. Use when authoring element.animate() motion, Animation currentTime seeking, document.getAnimations(), KeyframeEffect timing, fill modes, or native browser animations that must render deterministically in HyperFrames.
|
||||
---
|
||||
|
||||
# Web Animations API for HyperFrames
|
||||
|
||||
HyperFrames can seek Web Animations API animations through its `waapi` runtime adapter. WAAPI is useful when you want native browser keyframes with JavaScript-created timing and no GSAP dependency.
|
||||
|
||||
## Contract
|
||||
|
||||
- Create animations synchronously during composition initialization.
|
||||
- Use `element.animate(...)` with finite `duration` and `iterations`.
|
||||
- Use `fill: "both"` so seeked states persist.
|
||||
- Pause animations after creation or let the adapter pause them on first seek.
|
||||
- Avoid callbacks and promises for render-critical state.
|
||||
|
||||
The adapter calls `document.getAnimations()`, sets each animation's `currentTime` to HyperFrames time in milliseconds, then pauses it.
|
||||
|
||||
## Basic Pattern
|
||||
|
||||
```html
|
||||
<div id="orb" class="clip orb" data-start="2" data-duration="3" data-track-index="2"></div>
|
||||
|
||||
<script>
|
||||
const orb = document.getElementById("orb");
|
||||
const animation = orb.animate(
|
||||
[
|
||||
{ transform: "translate3d(-160px, 0, 0) scale(0.8)", opacity: 0 },
|
||||
{ transform: "translate3d(0, 0, 0) scale(1)", opacity: 1, offset: 0.35 },
|
||||
{ transform: "translate3d(120px, 0, 0) scale(1.08)", opacity: 1 },
|
||||
],
|
||||
{
|
||||
duration: 3000,
|
||||
delay: 2000,
|
||||
easing: "cubic-bezier(0.2, 0, 0, 1)",
|
||||
fill: "both",
|
||||
iterations: 1,
|
||||
},
|
||||
);
|
||||
|
||||
animation.pause();
|
||||
</script>
|
||||
```
|
||||
|
||||
## Stagger Pattern
|
||||
|
||||
```js
|
||||
document.querySelectorAll(".token").forEach((token, index) => {
|
||||
const animation = token.animate(
|
||||
[
|
||||
{ transform: "translateY(24px)", opacity: 0 },
|
||||
{ transform: "translateY(0)", opacity: 1 },
|
||||
],
|
||||
{
|
||||
duration: 620,
|
||||
delay: index * 80,
|
||||
easing: "cubic-bezier(0.2, 0, 0, 1)",
|
||||
fill: "both",
|
||||
iterations: 1,
|
||||
},
|
||||
);
|
||||
animation.pause();
|
||||
});
|
||||
```
|
||||
|
||||
## Good Uses
|
||||
|
||||
- Lightweight DOM motion where CSS keyframes are too rigid and GSAP is unnecessary.
|
||||
- Generated animations from structured data.
|
||||
- Simple timelines that can be represented as keyframes, delays, and offsets.
|
||||
|
||||
## Composition Duration
|
||||
|
||||
The render engine needs the composition's total length to know how many frames to capture. GSAP timelines report duration automatically; a WAAPI-only composition has no timeline object, so the runtime infers duration from every animation's `effect.getComputedTiming().endTime` (offset by when the animation was created relative to composition start). `data-duration` on the root element is optional as long as every `element.animate()` call uses finite `duration` and `iterations` — which the contract above already requires.
|
||||
|
||||
Infinite `iterations` has no finite `endTime`, so it can't be auto-inferred — that's one more reason to avoid it (see Avoid below). If you must use it, add `data-duration="<seconds>"` to the root `[data-composition-id]` element or `npx hyperframes lint` will error (`root_composition_missing_duration_source`).
|
||||
|
||||
## Avoid
|
||||
|
||||
- Infinite `iterations`.
|
||||
- Depending on `animation.finished` to mutate render-critical DOM.
|
||||
- Running separate clocks with `requestAnimationFrame`, timers, or `performance.now()`.
|
||||
- Animating layout properties when transforms and opacity can express the motion.
|
||||
- Assuming clip-local start time is automatic. WAAPI adapter seeks document-level animation time; model clip offsets with `delay` or create the animation on an element whose visibility is controlled by HyperFrames timing.
|
||||
|
||||
## Validation
|
||||
|
||||
After editing a WAAPI composition:
|
||||
|
||||
```bash
|
||||
npx hyperframes lint
|
||||
npx hyperframes check
|
||||
```
|
||||
|
||||
## Credits And References
|
||||
|
||||
- HyperFrames adapter source: `packages/core/src/runtime/adapters/waapi.ts`.
|
||||
- Duration auto-inference: `packages/core/src/runtime/init.ts` (`resolveAdapterDurationFloorSeconds`), `getInferredDurationSeconds` in the adapter above.
|
||||
- MDN Web Animations API guide: https://developer.mozilla.org/docs/Web/API/Web_Animations_API/Using_the_Web_Animations_API
|
||||
- MDN `Animation.currentTime`: https://developer.mozilla.org/en-US/docs/Web/API/Animation/currentTime
|
||||
@@ -0,0 +1,146 @@
|
||||
# Blueprints (the proven shapes)
|
||||
|
||||
> Entry point to the blueprint layer. Read this to find the shape for a frame; read `blueprints/<id>.md` to instantiate it. The Step-4 method (Reproduce / Adapt / Compose, what to write per frame) lives in `visual-design.md` — this file is the menu + the picker.
|
||||
|
||||
A **blueprint** is a product-agnostic, **time-coded shot template** — `Scene N (a–b s): …` with `[slots]` and one named **signature move** — reverse-engineered from 50 golden product-launch clips (plus 13 hyperframes-animation blueprints reverse-translated to the same brief format). It encodes a whole shot across its full duration — reveals paced to the spoken line, not dumped at t=0 — so instantiating one structurally keeps content arriving instead of freezing. The full template lives in `blueprints/<id>.md`. **Step 4 (visual design) instantiates one blueprint per frame** (or composes from the motion vocabulary when none fits).
|
||||
|
||||
## The 15 blueprints
|
||||
|
||||
<blueprints>
|
||||
<blueprint id="kinetic-type-beats" roles="Hook, Problem, Product_Intro, Benefits, CTA, Brand_Outro" duration="3.4–12s">
|
||||
Flat, centered, bold-type shot where the **motion IS the words changing** — a fixed line swaps tokens in place by hard cut, or a statement builds across full-screen beats (each its own move) onto a spring-pop payoff. The workhorse (6 roles). Reach for it whenever the words carry the shot and there's no set, surface, or click.
|
||||
</blueprint>
|
||||
|
||||
<blueprint id="typewriter-reveal" roles="Hook, Brand_Outro" duration="3.6–7s">
|
||||
A live text caret **types (and edits) a line as a human would**, then collapses it and pops a brand payoff, or holds it under a persistent mark while a sub-line types into the final CTA. Reach for it when "someone is typing this" should be the engine — a relatable typed pain → brand, or a standing logo + a typed CTA rail.
|
||||
</blueprint>
|
||||
|
||||
<blueprint id="spatial-pan-stations" roles="Hook, Problem" duration="7–10s">
|
||||
Pre-placed labeled **stations on one oversized canvas, traversed by a single virtual camera** — repeated lateral/diagonal pans centering each station and revealing a callout, landing held on the last. Reach for it for a milestone timeline panned to "us," or a connected web of pain stations ending in a tangled knot.
|
||||
</blueprint>
|
||||
|
||||
<blueprint id="constellation-hub" roles="Hook, Social_Proof" duration="5–6s">
|
||||
Iconned **nodes spring into a ring around a center**, then resolve on the core — a camera push-IN (depth-of-field collapsing onto it) or a held hub mark with satellites orbiting it. Reach for it for "it connects everything / one hub" or "sits at the center of your stack."
|
||||
</blueprint>
|
||||
|
||||
<blueprint id="grid-card-assemble" roles="Key_Feature, Benefits, Social_Proof" duration="3.0–10.5s">
|
||||
N items (tiles / cards / logos / list-lines) **self-assemble in a staggered cascade** into a grid or vertical list and hold; an optional camera zoom-OUT reveals the array inside a vaster whole. Reach for it to enumerate breadth at once — a feature grid, an accumulating benefit list, or a logo wall.
|
||||
</blueprint>
|
||||
|
||||
<blueprint id="logo-assemble-lockup" roles="Product_Intro, CTA, Brand_Outro" duration="4.6–11s">
|
||||
A brand mark / wordmark **builds itself from parts** (elements assemble/orbit, letters cascade, an outline draws on, or a camera pushes through negative space) and resolves into a centered lockup, optionally extended to a URL/CTA. Reach for it for a wordless premium brand sting, or a logo build leading into the final ask.
|
||||
</blueprint>
|
||||
|
||||
<blueprint id="cursor-ui-demo" roles="Product_Intro, Key_Feature" duration="4.0–9.3s">
|
||||
A visible custom **cursor drives a reconstructed app UI** through clicks/hovers/drags so the screen changes state shot-to-shot, while the camera chases each interaction. Reach for it for a first cursor-led look at the surface (Product_Intro) or one workflow demonstrated end-to-end onto the action button (Key_Feature).
|
||||
</blueprint>
|
||||
|
||||
<blueprint id="device-surface-showcase" roles="Key_Feature" duration="7.8–9.6s">
|
||||
A **device mockup or floating window held as hero** while its screens cycle through a real flow, presented by a camera ranging from a static hold to a continuous 3D push. Role-narrow (Key_Feature only) but mechanic-rich (static tour · floating-window push-scroll · 3D-hand demo). Reach for it to show a feature experienced *inside its real interface*.
|
||||
</blueprint>
|
||||
|
||||
<blueprint id="dataviz-countup" roles="Problem, Product_Intro" duration="6–12s">
|
||||
Numbers and charts are the hero — a **count-up ring/number, trend chart, tilted stat grid** — traversed by a camera that pushes THROUGH (or scrolls across) them to land on one hero metric. Reach for it when the data carries the argument: quantify a worsening problem, or open confidently on "look at the result."
|
||||
</blueprint>
|
||||
|
||||
<blueprint id="titlecard-reveal" roles="Benefits, Social_Proof" duration="3–5s">
|
||||
The calm **breather/landing beat** — one clean title or single brand/proof card revealed with exactly ONE restrained move (slide-up crossfade, or wipe-away-to-reveal), then a still hold. Low motion is the payload, not a deficiency. Reach for it for a two-line value title, or a busy open wiped to a clean lockup + a "loved by N+ teams" stat.
|
||||
</blueprint>
|
||||
|
||||
<blueprint id="comparison-split" roles="Key_Feature" duration="4–6s">
|
||||
Two paired items of equal weight enter from opposite wings with **mirrored 3D "book-open" tilts** and hold side-by-side, then an inner-edge pill badge spring-pops on each to punctuate. Reach for it for an A/B or "X + Y together" — two complementary capabilities weighed at once (not >2 items, not sequential steps).
|
||||
</blueprint>
|
||||
|
||||
<blueprint id="overwhelm-surround" roles="Problem" duration="6–9s">
|
||||
Overwhelm by accumulation — recognizable surfaces assemble, density-marker icons scatter in, the center one **morphs into the viewer's own avatar**, then elements close in from all sides (surrounded, not zoomed-into). Reach for it when the pain is "you're buried in tools," ending on a claustrophobic crowd.
|
||||
</blueprint>
|
||||
|
||||
<blueprint id="ticker-takeover" roles="Hook, Brand_Outro" duration="5–7s">
|
||||
A typed lead-in + an accent word cycling through options, then a hero **crashes in from off-screen and physically shoves the text aside** — a collision, not a fade — settling alone. Reach for it when a "could be many things" build should be violently replaced by "this is it."
|
||||
</blueprint>
|
||||
|
||||
<blueprint id="video-text-pivot" roles="Product_Intro, Key_Feature" duration="6–8s">
|
||||
A product video holds center and breathes, then **slides aside to hand its weight to a hero stat**, then both clear and kinetic text types into the vacated center, sealed by a gradient pill. Reach for it for "see the feature → see the impact" where the video must stay visible (slides, never cuts).
|
||||
</blueprint>
|
||||
|
||||
<blueprint id="cta-morph-press" roles="CTA" duration="4–6s">
|
||||
A resting brand mark **condenses at the same center into a brighter CTA**, then a cursor arrives and lands a human-aimed click with feedback. Reach for it for a focused "click here" sign-off that walks the eye from identity to action — no spatial set, no multi-step UI.
|
||||
</blueprint>
|
||||
</blueprints>
|
||||
|
||||
## Role → blueprint menu
|
||||
|
||||
A **SOFT** menu: story truth comes first. Story-design reaches in **when the product's own beat calls for that shape** — it suggests a proven shape, it never dictates which beats exist. Each role has 2–4 options; if none fits the beat, compose freely (the menu is not a checklist). Each line is the **trigger** that should make you reach for that blueprint.
|
||||
|
||||
Roles here map 1:1 to the storyboard frame `type` enum: **Hook**=`hook` · **Problem**=`pain_point` · **Product_Intro**=`product_intro` · **Key_Feature**=`feature_showcase` · **Benefits**=`benefit_highlight` · **Social_Proof**=`social_proof` · **CTA**=`cta` · **Brand_Outro**=`branding`.
|
||||
|
||||
**Hook**
|
||||
|
||||
- `kinetic-type-beats` — a punchy rhetorical line / "you keep doing X" callout where the in-place word-swap is the joke, OR an escalating multi-beat statement landing a spring-pop payoff.
|
||||
- `typewriter-reveal` — type a relatable line, collapse it, pop the brand (logo or product-UI) — "here's the everyday pain, now here's us."
|
||||
- `spatial-pan-stations` — a timeline of milestones panned to the present ("evolution leading up to us").
|
||||
- `constellation-hub` — a constellation of tools/nodes + a camera push-in ("it connects everything").
|
||||
- `ticker-takeover` — a cycling accent word ("could be X, or Y…") violently replaced by a hero crashing in from off-screen.
|
||||
|
||||
**Problem**
|
||||
|
||||
- `kinetic-type-beats` — 3–5 short pain statements, each landing alone on a bare canvas, no product yet.
|
||||
- `spatial-pan-stations` — pan a connected web of pain "stations" ending in a tangled knot.
|
||||
- `dataviz-countup` — a count-up ring / chart / stat grid pushed-through to dramatize a worsening or large problem.
|
||||
- `overwhelm-surround` — recognizable tools that morph into the viewer, then task bubbles close in from all sides ("you're buried").
|
||||
|
||||
**Product_Intro**
|
||||
|
||||
- `kinetic-type-beats` — "Introducing…" hard-cut name-drop resolving on the brand name/logo.
|
||||
- `logo-assemble-lockup` — a wordless premium brand sting (elements pulse/orbit and assemble around the mark).
|
||||
- `cursor-ui-demo` — first look at the product surface; a cursor sweeps in to introduce the app.
|
||||
- `dataviz-countup` — hard-cut into a data-viz card grid, camera scrolls to a glowing hero metric + a kinetic tagline.
|
||||
- `video-text-pivot` — a product video that slides aside to hand its weight to a hero stat, then yields the center to kinetic impact text.
|
||||
|
||||
**Key_Feature**
|
||||
|
||||
- `grid-card-assemble` — a labeled feature tile/pill grid that self-assembles (or glass cards revealed by a camera zoom-out).
|
||||
- `cursor-ui-demo` — a specific multi-step workflow demonstrated end-to-end, landing on the action button/result.
|
||||
- `device-surface-showcase` — a device/window hero whose screens cycle (static tour · floating-window push-scroll · 3D-hand demo).
|
||||
- `comparison-split` — two paired capabilities side-by-side with mirrored book-open tilts (an A/B / "X + Y together").
|
||||
- `video-text-pivot` — a feature clip that slides aside to a frame-filling metric, then a typographic impact line.
|
||||
|
||||
**Benefits**
|
||||
|
||||
- `kinetic-type-beats` — a rapid-fire staccato montage of 8–12 short value phrases.
|
||||
- `grid-card-assemble` — a vertical benefit list that accumulates or steps.
|
||||
- `titlecard-reveal` — a calm two-line value title card (a breather/stillness beat).
|
||||
|
||||
**Social_Proof**
|
||||
|
||||
- `constellation-hub` — product mark as the hub, partner logos orbiting it ("works with your stack").
|
||||
- `grid-card-assemble` — a logo wall that builds then pulls back to reveal a vast ecosystem.
|
||||
- `titlecard-reveal` — wipe a busy open away to a clean brand lockup + a "loved by N+ teams" stat.
|
||||
|
||||
**CTA**
|
||||
|
||||
- `kinetic-type-beats` — a punchy closing line (or short value stack) snapping beat-by-beat onto the logo/URL.
|
||||
- `logo-assemble-lockup` — a logo build → camera push-through into the final URL/CTA verb.
|
||||
- `cta-morph-press` — a brand mark that condenses into the CTA at one center, then a cursor lands a human-aimed click.
|
||||
|
||||
**Brand_Outro**
|
||||
|
||||
- `kinetic-type-beats` — a rapid verb barrage resolving on the brand's one defining word.
|
||||
- `typewriter-reveal` — a persistent brand mark with a typed/swapping CTA rail beneath it.
|
||||
- `logo-assemble-lockup` — feature/UI elements clear the stage and the lockup draws itself in.
|
||||
- `ticker-takeover` — options cycle, then the brand mark crashes in and owns the frame.
|
||||
|
||||
> Coverage: every role has ≥2 options; every blueprint serves ≥1 role. `kinetic-type-beats` is the workhorse (6 roles); `device-surface-showcase` is role-narrow (Key_Feature only) but mechanic-rich. Five shapes — `comparison-split`, `overwhelm-surround`, `ticker-takeover`, `video-text-pivot`, `cta-morph-press` — were added from the hyperframes-animation blueprints.
|
||||
|
||||
## Picking guidance
|
||||
|
||||
1. Find the frame's **role** in the menu above; pick the blueprint whose **shape fits this beat** (story may already have named a candidate id — confirm or override). If two fit, prefer the one whose motions are closer to your plan.
|
||||
2. Open `blueprints/<id>.md` — read its time-coded template, `[slots]`, and named **signature move**.
|
||||
3. Choose a posture — **Reproduce** (slots map cleanly), **Adapt** (structure fits, content/surface differs; keep the signature move), or **Compose** (nothing fits → build from the motion vocabulary). The _how_ of writing each — what to keep/change, the per-frame fields — is `visual-design.md`'s job; defer to it.
|
||||
4. If nothing in the menu fits the beat, **compose** from the motion vocabulary in `motion-language.md` — still pace the reveals to the VO across the shot. Don't force a wrong blueprint.
|
||||
|
||||
## Motion coverage
|
||||
|
||||
Every recurring move in the golden vocabulary is backed by this skill's local `rules/` — including five added to round out the corpus: `depth-of-field-blur`, `motion-blur-streak`, `depth-scatter-assemble`, `spring-pop-entrance` (the canonical entrance pop, distinct from the click/press `press-release-spring`), and `ambient-glow-bloom`. Each blueprint's `rule mapping` cites the real rule.
|
||||
|
||||
One genuine out-of-scope special remains: `device-surface-showcase`'s **3D-hand gesture-input + WebGL bloom/portal** needs R3F/Three.js + WebGL — a heavier capability than the rule library. Use it sparingly, or pick a simpler `device-surface-showcase` variant.
|
||||
@@ -0,0 +1,27 @@
|
||||
# comparison-split — Comparison Split-Cards
|
||||
|
||||
**intent**: Two paired items of equal weight shown side-by-side with mirrored 3D "book-open" tilts — the eye reads them as a balanced comparison, then a pill badge lands at each card's inner edge to punctuate. The motion IS the symmetry: two cards arriving from opposite wings into a held spread.
|
||||
|
||||
**roles served**
|
||||
|
||||
- Key_Feature (from `comparison-split-cards`): when two complementary features / capabilities of equal weight should be presented **simultaneously, not sequentially** — an A/B, a "X + Y together," paired concepts the viewer must weigh side-by-side. Not for >2 items (use `grid-card-assemble`) or sequential steps.
|
||||
|
||||
**duration**: 4–6s
|
||||
|
||||
**shot structure** (a `[bg]` canvas carrying two faint ambient glow blooms — `[accent A]` near 30%, `[accent B]` near 70% — so each side owns a color identity across a 50% symmetry axis; equal-width cards under one shared perspective parent)
|
||||
|
||||
- **Scene 1 (0.0–~0.8s) — title sets the concept.** A centered `[title line]` with an `[accent keyword]` slides DOWN into place from just above (a short smooth settle). The downward arrival is deliberate: it forms a non-conflicting T-shape against the cards, which arrive from the sides next.
|
||||
- **Scene 2 (~0.4–1.9s) — the split-tilt entry (signature move).** Two equal-width feature cards arrive from opposite wings — `[left card]` from the left, `[right card]` from the right ~0.2s behind — each carrying a **mirrored 3D `rotateY` tilt** (left faces right, right faces left, opening like a book) and scaling ~0.85→1 as it lands. The entry overlaps the title's tail so the whole thing reads as ONE arrival, not two beats. Each card holds `[image / label / subtitle]`; box-shadows fall **outward** from the tilt (left shadow right, right shadow left).
|
||||
- **Scene 3 (~1.9–end) — badges punctuate, then hold.** A pill `[badge]` lands at each card's **inner edge** (left then right, ~0.3s apart), overlapping its card ~15% so it reads as attached, not orbiting. This is the lone overshoot in the shot — it earns the punctuation. Settles and holds.
|
||||
|
||||
**motion vocabulary**: title slide-down from above; mirrored opposite-wing card entry; static book-open `rotateY` tilt (`+tilt` left, `−tilt` right); tilt-matched outward box-shadow; inner-edge badge spring-pop; gentle phase-opposed idle float (left vs right, never synchronized) registered as subtle jitter; dual side-glow ambient.
|
||||
|
||||
**rule mapping**
|
||||
|
||||
- two cards entering from opposite wings with mirrored `rotateY` tilts + tilt-matched shadow → `split-tilt-cards` (the signature; keep the two-layer split so the entry `x`/`scale` and the idle never collide on one alias)
|
||||
- title slide-down settle → `gsap-effects` (translate + opacity on a long-tail `power3`)
|
||||
- inner-edge pill badge pop (the one overshoot) → `spring-pop-entrance` (overshoot register — earns the punctuation)
|
||||
- phase-opposed idle float on the pair → `sine-wave-loop` (low-amplitude register — subtle jitter, NOT lazy breathing; left `sin(t)`, right `sin(t+π)` so they never conveyor-belt)
|
||||
- the two faint side glows behind the cards → `ambient-glow-bloom` (un-triggered soft bloom, one per accent)
|
||||
|
||||
**camera modifier**: camera-static by default — the symmetry is the subject and a move would break the balance.
|
||||
@@ -0,0 +1,44 @@
|
||||
# constellation-hub — Constellation / Hub + Satellites
|
||||
|
||||
**intent**: Labeled/iconned nodes spring into a ring/cluster around a center, then the shot resolves on the core — either by pushing the camera INTO the center (depth-of-field collapsing onto it) or by holding a hub mark while the satellites ORBIT it; the "everything connects to / sits around one center" beat.
|
||||
|
||||
**roles served**
|
||||
|
||||
- Hook (from `hook-cluster-push-in`): a constellation of tool/app nodes springs into a wide ring, then a sustained camera push-in with depth-of-field resolves on the inner core — "it connects everything / one hub for all your tools."
|
||||
- Social_Proof (from `social-proof-orbit-ecosystem`): the product brand mark lands as the center hub and partner logos spring onto a ring and revolve around it — "plugs into / sits at the center of your stack."
|
||||
- CTA (from `cta-orbit-collapse`): the ring resolves by COLLAPSE rather than a push-in — category icons drift around an empty central CTA, a cursor click implodes the orbit toward the click point, and the product demo springs OUT of that collapse as the answer (scope → choice → consequence → product).
|
||||
- Social_Proof (from `proof-logo-chain`): a persistent center logo accrues proofs — its wordmark decodes, a claim ticker swaps, the logo glides to center, then avatars cascade into orbit with drawn connectors while partner logos scroll the bottom strip; four claims read as one statement.
|
||||
|
||||
**duration**: 5–8s (Hook 5–6s · Social_Proof 5–8s · CTA orbit-collapse ~6s)
|
||||
|
||||
**shot structure**
|
||||
|
||||
Consolidated template — nodes ring a center, then one of two finishers resolves on the core.
|
||||
|
||||
- Scene 1 (0.0–~1.5s): `[bg]` (dark/space field, optionally slow-drifting diffused gradient blobs). `[primary nodes]` (circles carrying `[icon]` + label) SPRING-POP in (scale 0→1, ~1.15 elastic overshoot, staggered) arranged in a wide ring/cluster around an empty or marked center `[hub]`.
|
||||
- Scene 2 (~0.7–2.5s, overlapping): smaller `[secondary nodes]` (platform / partner-logo chips) pop in staggered with the same elastic spring, filling the gaps; optional thin `[accent]` connector lines / orbit ring draw from hub→nodes. Camera holds.
|
||||
- Scene 3 (~2.5–Xs, the resolve): see finisher variant below; lands and HOLDS on the magnified / orbited center to the end.
|
||||
|
||||
- Variant — Hook (push-in finisher): from Scene 3, a continuous smooth CAMERA PUSH-IN toward the center inner cluster — inner nodes scale up and stay sharp while outer nodes are pushed toward the edges and progressively BLUR (depth-of-field), background scales up smoothly; holds magnified on the core.
|
||||
- Variant — Social_Proof (orbit finisher): the center `[brand mark]` snaps in via a quick 3D rotate that decelerates and settles; a thin `[accent]` orbit ring draws around it; `[N partner badges]` spring onto the ring (staggered overshoot) and revolve CLOCKWISE while staying upright, under a continuous slow camera ZOOM-OUT (ecosystem reveal).
|
||||
- Variant — Social_Proof (optional type-push-through opener, prepended before Scene 1): centered `[headline]` types/slides in with a huge transparent-fill OUTLINE copy of the same words behind it; the outline text scales up exponentially toward camera (high-speed dolly / push-through), breaches the frame, then HARD-CUTS to the hub bg of Scene 1.
|
||||
|
||||
**motion vocabulary**: staggered elastic spring-pop node entrances (~1.15 overshoot); slow gradient-blob drift; connector-line / orbit-ring draw-on; 3D snap-rotate-settle on the hub mark; continuous camera push-in (inner sharp, outer depth-of-field blur, bg scale-up); clockwise orbital revolve of upright badges; continuous slow camera zoom-out (ecosystem reveal); optional outline-text push-through dolly entry.
|
||||
|
||||
**rule mapping** (motion verb → `rules/<id>.md`)
|
||||
|
||||
- staggered spring-pop node entrances → `spring-pop-entrance` (elastic overshoot) + `gsap-effects` (stagger recipe); 3D-flip-in flavor → `orbit-3d-entry`
|
||||
- ring / cluster layout of nodes around a center → `avatar-cloud-network` (nodes on an elliptical ring + SVG lines to a center)
|
||||
- icons on the nodes → `svg-icon-enrichment`
|
||||
- connector lines hub→node → `svg-path-draw`
|
||||
- orbit-ring draw-on → `svg-path-draw`
|
||||
- slow gradient-blob drift → `sine-wave-loop` (idle looped drift)
|
||||
- 3D snap-rotate-settle on hub mark → `orbit-3d-entry` (3D-flip entry); technique CSS-3D
|
||||
- clockwise orbital revolve of upright badges → `orbit-3d-entry` (continuous elliptical orbit); technique MotionPath
|
||||
- camera push-in toward center → `multi-phase-camera` (PUSH-in) + `coordinate-target-zoom` (target the core)
|
||||
- background scale-up during push-in → `multi-phase-camera`
|
||||
- continuous slow zoom-out (ecosystem reveal) → `multi-phase-camera` (pull-back) / `coordinate-target-zoom`
|
||||
- outline-text push-through dolly opener (Social_Proof) → `3d-text-depth-layers` (outline copy behind) + `multi-phase-camera` (push-through)
|
||||
- depth-of-field blur on outer nodes during push-in → `depth-of-field-blur` (progressive DOF/focus-falloff blur on the off-center outer nodes while the inner core stays sharp)
|
||||
|
||||
**camera modifier**: push-in-with-DOF (Hook) — `multi-phase-camera` PUSH-in targeted via `coordinate-target-zoom` onto the core; the focus-falloff blur half of it is backed by `depth-of-field-blur`. Orbit finisher (Social_Proof) — slow continuous zoom-out via `multi-phase-camera` (pull-back) while satellites revolve.
|
||||
@@ -0,0 +1,28 @@
|
||||
# cta-morph-press — CTA Morph & Press
|
||||
|
||||
**intent**: A resting brand mark condenses at the same screen center into a smaller, brighter CTA, then a cursor arrives from off-stage and lands a human-aimed click on it. The viewer's eye is walked from "this is who we are" to "and this is what you do." The morph and the click are the two headline beats.
|
||||
|
||||
**roles served**
|
||||
|
||||
- CTA (from `cta-morph-press`): when the close moves from brand identity to a single user action, two elements share the same center sequentially (a morph, not a cut), and the payoff is a simulated click with physical feedback. Reach for it for a focused "click here" sign-off — no spatial set, no multi-step UI (that's `cursor-ui-demo`).
|
||||
|
||||
**duration**: 4–6s
|
||||
|
||||
**shot structure** (a `[bg]` canvas; hero and CTA are flex-centered siblings sharing one `transform-origin`)
|
||||
|
||||
- **Scene 1 (0.0–~1.4s) — presence.** The `[hero mark / brand lockup]` holds dead-center, alive but resting — only a faint rotational breath on the mark; any title text under it stays rock-stable. Camera static.
|
||||
- **Scene 2 (~1.4–2.4s) — the morph (signature move).** The hero CONDENSES at the same screen center into a smaller, brighter `[CTA]` (button / card): the outgoing mark shrink-fades exactly as the CTA scales up in its place. Because they share one `transform-origin`, the eye reads it as one element transforming, not a swap.
|
||||
- **Scene 3 (~2.4–3.4s) — approach.** A `[cursor]` arrives from off-stage on a **decelerating** path (it "arrives," it does not pass through) and lands a few px **off** the CTA's geometric center, so the aim reads human, not scripted.
|
||||
- **Scene 4 (~3.4–end) — press.** The cursor lands a physical CLICK — cursor and CTA compress together in lockstep, then release with feedback (an optional ripple / glow bloom). Holds on the clicked state.
|
||||
|
||||
**motion vocabulary**: faint rotation-only resting breath (logo scope only); same-center morph-swap (shrink-fade ↔ scale-up sharing `transform-origin`); cursor decel-arrival from off-stage; off-center human aim; lockstep press compression; release feedback ripple / glow.
|
||||
|
||||
**rule mapping**
|
||||
|
||||
- hero → CTA condense at one center → `scale-swap-transition` (shared `transform-origin: 50% 50%` is what sells the morph; CTA `position: absolute` so it doesn't shove the hero during the brief overlap)
|
||||
- resting-hero aliveness (rotation only, scoped to the mark so the Phase-2 scale doesn't fight it) → `sine-wave-loop` (low-amplitude rotation register — subtle jitter, not a scale breath)
|
||||
- cursor press + release in lockstep (single-target-array so both compress together) → `physics-press-reaction` (PRESS_DOWN + RELEASE portion)
|
||||
- cursor approach (decel from off-stage, off-center landing, hard-cut opacity in) → `gsap-effects` (translate on `power2.out`)
|
||||
- click ripple / release glow → `cursor-click-ripple` (attack-decay ring) and/or `ambient-glow-bloom` (release bloom)
|
||||
|
||||
**camera modifier**: camera-static — the morph and click happen in element space; a camera move would compete with the click as the climax.
|
||||
@@ -0,0 +1,57 @@
|
||||
# cursor-ui-demo — Cursor-Driven UI Demo
|
||||
|
||||
**intent**: A visible custom cursor drives a real (reconstructed) app UI through clicks / hovers / drags so the screen changes state shot-to-shot, while the camera chases each interaction — the product surface is the subject and the cursor is the actor.
|
||||
|
||||
**roles served**
|
||||
|
||||
- Product*Intro (from `product-intro-cursor-ui-demo` / #14 Product_Intro_02, #15 Product_Intro_2, #17 Product_Intro_04): first look at the product surface — the cursor sweeps/hovers to \_introduce* the app and reveal what it is, landing on a hovered hero element or freshly-popped result. Light, exploratory; backdrop steps colors as it goes.
|
||||
- Key*Feature (from `key-feature-cursor-ui-demo` / #23 Key_Feature_2, #24 Key_Feature_03, #27 Key_Feature_06): one specific multi-step workflow demonstrated \_end-to-end* (edit / configure / select across 2–4 discrete beats), each beat a real edit the UI responds to live, landing locked on the primary action button or the produced result.
|
||||
- Key_Feature (from `workflow-approve-press`): an agency / confirmation workflow framed by a cockpit of 3D-tilted flanks — a step list ticks pending → active → complete (a snap state machine, CSS responding to `[data-state]`), and a flank button takes the PRESS as the payoff (its color flips to success, a checkmark stamps). The click is the climax, not a passing gesture.
|
||||
|
||||
**duration**: 4.0–9.3s (union of Key_Feature 4.0–7.3s and Product_Intro 6.1–9.3s)
|
||||
|
||||
**shot structure** (a `[product UI surface]` — fixed app window, dashboard/editor, parallax `[content card]` stack, or a `[container object/icon]` — centered over `[bg color/gradient]`, shown `[flat]` or `[3D-isometric]`; a custom `[brand-colored cursor with icon]` is the protagonist and the camera servos to whatever it touches; UI responds _live_ and in sync with each cursor action. Two role-tuned tempos fold in — Product_Intro **sweeps to introduce**, Key_Feature **performs a workflow**.)
|
||||
|
||||
- **Scene 1 (0.0–~Xs) — surface establishes + first touch.** The `[product UI surface]` arrives centered over `[bg color/gradient]` — either it is simply present (fixed window / dashboard / editor), a 3D-parallax stack of `[content cards]`, or a `[container object/icon]` that FLIES IN with a 3D tumble and settles. The custom `[cursor]` enters. The cursor performs the FIRST action on `[cursor target 1]` and the UI responds live in the same beat. Camera holds or begins a slow push-in toward the acted-on region.
|
||||
- _Variant — Product_Intro_: low-commitment first touch — cursor HOVERS/sweeps a control or SWEEP-HIGHLIGHTS a field to `[accent color]`, OR the `[container]` fans open. An optional label/title fades/morphs onto the surface. The point is to _show the surface exists_ and is touchable.
|
||||
- _Variant — Key_Feature_: a concrete edit — cursor DRAGS a scrollbar / TYPES into a field / DRAGS a handle, and the UI responds materially (`[scroll]` / value climbs / region resizes). If the surface opened in `[3D-isometric]`, it may snap perspective-FLAT here to read the workflow.
|
||||
|
||||
- **Scene 2 (~Xs–~Ys) — camera chases to the next interaction (the engine).** The camera MOVES to the next target — push-in + pan / whip-pan / pan-down to `[cursor target k]` — and the cursor performs action k as the UI updates live. Each beat is a discrete interaction connected by a fast camera move; the surface's inner content SWAPS per interaction.
|
||||
- _Variant — Product_Intro_: navigation is exploratory — a slow camera pan + depth-of-field FOCUS-PULL across a parallax `[content card]` stack, or the `[container]` fanning into `[N option/content cards]` that SPRING to position. As content swaps, the supporting backdrop STEPS its color (`[bg step 1]` → step 2 → …). Typically one or two such moves.
|
||||
- _Variant — Key_Feature_: repeat for `[2–4 beats total]`, each a distinct operation the UI answers — counter COUNTS UP, `[pill/swatch]` SELECTS, a modal SLIDES UP and TYPES — connected by whip-pans / progressive zoom. The workflow visibly advances toward a result.
|
||||
|
||||
- **Scene 3 (~Ys–end) — payoff state, camera settles, HOLD.** The cursor lands on its final target and the screen reaches the payoff state; the camera comes to rest (static) and holds.
|
||||
- _Variant — Product_Intro_: the cursor HOVERS the hero element — a `[content card]` SCALES UP on hover, a node gets an `[Available]`-style pill, or a `[result card]` POPS/springs in — the "here's the product" payoff. Settles static, holds.
|
||||
- _Variant — Key_Feature_: locked close-up on the OUTCOME — cursor lands on the `[primary action button: Export / Save / Reimburse]` and a `[hover backdrop / highlight]` SPRING-pops in (the climax is the action button / produced result). Holds.
|
||||
|
||||
**motion vocabulary**: cursor-driven click / hover / sweep-highlight / drag / type; per-interaction live UI response (scroll, value climb, region resize, content swap); camera push-in + pan / whip-pan / pan-down servoing to each target; coordinate zoom onto the acted region; press-and-ripple on a clicked control; button press-compress; screen-state swap shot-to-shot; card fan-out to corners (spring); 3D container fly-in & tumble-settle; perspective-flatten (3D→2D snap); paginated/stepped backdrop color advance; depth-of-field focus-pull across a parallax card stack; counter count-up; pill/swatch select; modal slide-up + typing; label/title morph between states; UI-keyword highlight glow; terminal hover-scale or result-card pop-in; spring hover-backdrop on the final action button.
|
||||
|
||||
**rule mapping**
|
||||
|
||||
- viewport follows the cursor / camera servos to whatever it touches (primary) → `camera-cursor-tracking`
|
||||
- cursor moves to a target, presses, emits a ripple (the click itself — primary interaction primitive) → `cursor-click-ripple`
|
||||
- screen-state swap shot-to-shot (surface inner content changes between beats) → `scale-swap-transition`
|
||||
- camera push-in + pan / whip-pan / pan-down to the next target → `viewport-change` (pan/zoom across the UI)
|
||||
- sequencing the chase into discrete interaction beats → `multi-phase-camera`
|
||||
- zoom onto the specific acted-on UI region → `coordinate-target-zoom`
|
||||
- cursor icon/state changing with context (e.g. pointer↔grab over a draggable handle) → `context-sensitive-cursor`
|
||||
- which content appears per beat / step-by-step UI state progression / per-interaction swaps → `dynamic-content-sequencing`
|
||||
- sweep-highlight a field, highlight a UI keyword to `[accent color]` → `asr-keyword-glow` (keyword glow on the touched element)
|
||||
- clicked button compresses on press, springs back on release → `press-release-spring`
|
||||
- cursor + button compress together on a heavier press → `physics-press-reaction`
|
||||
- panel/card morphs between two states (e.g. card → expanded card, surface state A → B) → `card-morph-anchor`
|
||||
- terminal hover-scale, `[result card]` pop-in, spring hover-backdrop on the final action button → `spring-pop-entrance`
|
||||
- card fan-out to corners / option cards springing to position → `split-tilt-cards` (fan/spread into tilted positions) + `spring-pop-entrance` (the spring settle)
|
||||
- 3D-parallax content-card stack as the surface; UI shown 3D-isometric → `3d-page-scroll` (UI as a tilted scrolling/parallax card)
|
||||
- node gets an `[Available]`-style pill / tracked badge appears on an element → `ai-tracking-box`
|
||||
- counter / value count-up as the UI responds → `counting-dynamic-scale`
|
||||
- a result bar / number FILLS as the workflow's outcome → `stat-bars-and-fills`
|
||||
- a live `[video]` screen-capture clip used as the surface → technique: video compositing
|
||||
- perspective-flatten (3D-isometric → flat 2D snap) and the 3D-isometric tilt itself → technique: CSS-3D (no dedicated rule; the tilt/flatten transform is a CSS-3D primitive)
|
||||
- camera settles static on the payoff and HOLDS → (settle phase of `spring-pop-entrance` on the payoff element; the static hold itself needs no rule)
|
||||
- 3D container/object fly-in & tumble-settle → `depth-scatter-assemble` (free-tumbling 3D object/container entrance that flies in and tumble-settles; `orbit-3d-entry` only orbits a flat element into place)
|
||||
- depth-of-field focus-pull across the parallax card stack → `depth-of-field-blur` (rack-focus / DoF blur transition between near and far cards; `3d-page-scroll` supplies the tilted parallax stack and `viewport-change` the pan)
|
||||
- paginated/stepped backdrop color advance synced to interactions (`[bg step 1]`→step 2→…) → `discrete-text-sequence` (discrete state stepping, here applied to a background-color state rather than text)
|
||||
- modal slide-up + in-modal typing as one combined beat → `card-morph-anchor` / `scale-swap-transition` (the panel slide-in) + `discrete-text-sequence` (the in-modal typed text)
|
||||
|
||||
**camera modifier**: The defining motion is the camera CHASE — the viewport follows the cursor from target to target via `camera-cursor-tracking` (primary), realized as concrete push-in + pan / whip-pan / pan-down moves under `viewport-change`, sequenced into discrete interaction beats by `multi-phase-camera`, with each beat's destination targeted via `coordinate-target-zoom` (zoom to the acted-on region). Product_Intro biases toward a slow, exploratory pan + focus-pull that sweeps the surface; Key_Feature biases toward snappier whip-pans / progressive zoom that march through the workflow and lock static on the action button. This camera-servo-to-cursor is what separates the blueprint from hands-off camera scrolls (dataviz-scroll-reveal) and static device/window tours.
|
||||
@@ -0,0 +1,46 @@
|
||||
# dataviz-countup — Data-Viz / Count-Up
|
||||
|
||||
**intent**: Make numbers and charts the hero — a count-up ring/number, a trend chart, a tilted stat/card grid — and traverse the data instruments with a camera that pushes THROUGH them (or scrolls across them) to land on one hero metric, so the data itself carries the argument.
|
||||
|
||||
**roles served**
|
||||
|
||||
- Problem (from `problem-dataviz-pushthrough` / #9 Problem_1): quantifies the pain with real-looking instruments — a count-up ring → a trend chart → a stat grid — the camera pushing THROUGH each object into the next to dramatize a worsening / large-scale problem ("X% of people struggle with…").
|
||||
- Product_Intro (from `product-intro-dataviz-scroll-reveal` / #19 Product_Intro_06): a confident "look at the result / the data" open — hard-cut from a hook word into a perspective-tilted grid of data-viz cards, then a hands-off camera scroll lands one glowing hero metric while a kinetic tagline assembles word-by-word.
|
||||
- Hook (from `hook-counter-burst`): a cold-open hook on ONE dramatic statistic — the frame opens dark and empty, 3–5 thematic icons puncture in clustered at center, then the headline number EXPLODES upward in size as the icons fling outward to their marks (the count-up and the spread are one beat), closed by a slow camera lean-in. Kinetic from frame 1.
|
||||
|
||||
**duration**: ~4–12s (Hook ~4s · Product_Intro ~6s · Problem ~11–12s)
|
||||
|
||||
**shot structure**
|
||||
Data-viz field on `[bg color]` (dark or light, soft corner glows); `[gradient A→B]` brand stroke on charts/rings; clean sans-serif white/dark text; a continuous camera move runs underneath that traverses 2–3 data instruments and resolves on a hero metric. One instrument per beat; the camera carries the cut.
|
||||
|
||||
- Scene 1 (0.0–Xs): the first data instrument establishes centered — a `[stat]` reads as the hero. A bold center number COUNTS UP `[start]`→`[end]` (font-size growing with the value), with `[stat label]` below; its paired graphic (a circular progress RING sweeping to `[pct]` with a `[gradient]` stroke, or a bar/fill) animates in on the SAME ease so number + graphic land as one beat. Supporting `[avatar/object]` elements pop in with spring overshoot into a scattered glowing orbit; a `[headline]` fades up. A very slow continuous camera zoom-in runs throughout.
|
||||
- Scene 2 (Xs–Ys): the camera traverses to the next instrument and that instrument animates — a `[gradient]` trend line / area chart DRAWS left→right on grid lines (Problem), or off-center cards SCROLL away as the layout glides (Product_Intro). The arriving `[stat-2]` number counts up / the chart resolves.
|
||||
- Scene 3 / Scene N (…–end): the camera lands the `[hero metric card]` (big number + label + delta + rising chart) in dead-center; a soft `[accent]` glow blooms behind it; the move reaches its peak then eases to a settled, slightly wider composition with the hero centered and supporting cards flanking it. HOLD on the final frame.
|
||||
|
||||
- Variant — Problem (push-THROUGH, count-up → trend → grid): Scene 1 is a centered circular progress ring + count-up center number with scattered glowing `[avatar/object]` orbit. Scene 2 is a fast camera PUSH-IN straight through the center of the ring (ring, number, orbiting elements scale up and fly out of frame) into a rounded `[card]` holding `[stat-2 header]` over a `[gradient]` line chart with grid lines + translucent area fill that draws left→right; camera pushes through then settles. Scene 3: camera PANS to a second `[card]` whose number counts up, holding a grid of the `[avatar/object]` elements — a subset dim/blur while the rest receive `[accent]` circular checkmark badges that SPRING-POP; camera settles to the end. The traversal is z-depth push-through between instruments.
|
||||
- Variant — Product_Intro (scroll-to-hero + word-by-word tagline): a brief opener — Scene 0 (~0.0–0.85s): a full-frame `[hero-color orb]` with a bold white `[hook phrase]` over it; static shimmer, then HARD CUT. Scene 1 cuts to a slightly perspective-TILTED grid of `[data-viz / product cards]` (charts, heatmaps, stat cards with deltas + source footers) with `[tagline word 1]` centered; the grid begins SCROLLING (e.g. toward upper-left) with its tilt held. Scene 2: the grid keeps scrolling so the `[hero metric card]` glides into dead-center as off-center cards slide away; `[tagline word 1]` translates out and `[word 2]` rises in from a frame edge. Scene 3: hero card settles centered, `[accent]` glow blooms behind it, camera PUSHES IN slightly; `[word 2]` holds near it. Scene 4: `[word 2]` slides out, the final `[tagline word]` drops in from the opposite edge above the still-glowing hero, push-in peaks. Scene 5: overlay type clears, camera eases BACK OUT to a settled wider tilted composition — hero centered with glow, supporting cards flanking. The traversal is a hands-off camera SCROLL across a tilted card plane (no cursor, no clicks) + a one-word-at-a-time kinetic headline + push-in-then-out bookend.
|
||||
|
||||
**motion vocabulary**
|
||||
count-up number with font-size growing on the value; circular progress-ring sweep; growth bar / progress fill; gradient trend-line + area-fill left→right draw; spring-overshoot pop-in of scattered glowing avatar/object elements; perspective-tilted card grid; directional grid scroll (cards glide in/out of center); hero-card centering; soft accent glow bloom behind the hero; slow continuous zoom-in; fast camera push-IN / push-THROUGH the center of an instrument; lateral/vertical camera pan between cards; gentle push-in that peaks then eases back out to a wider settle; selective dim/blur of a subset + spring-pop checkmark badges; full-frame hook orb → hard cut; kinetic tagline assembled word-by-word (each word drops/rises from a frame edge, prior word slides out).
|
||||
|
||||
**rule mapping** (motion verb → `rules/<id>.md`)
|
||||
|
||||
- count-up number whose font-size grows with the value → `counting-dynamic-scale` (primary text rule)
|
||||
- circular progress-ring sweep (the ring fill) → `stat-bars-and-fills` (ring form) — its draw mechanics delegate to → `svg-path-draw`
|
||||
- growth bars / progress fill paired beside a number → `stat-bars-and-fills` (primary data rule)
|
||||
- gradient trend-line / area-chart left→right draw → `svg-path-draw` (a path/line draws itself)
|
||||
- spring-overshoot pop-in of the avatar/object elements → `spring-pop-entrance` (elastic overshoot); the scattered-ring layout of glowing avatars/objects → `avatar-cloud-network`; if they keep drifting/orbiting → `orbit-3d-entry`
|
||||
- spring-pop `[accent]` checkmark badges → `spring-pop-entrance`
|
||||
- perspective-tilted card grid (tilt held static while content moves) → `3d-page-scroll`
|
||||
- directional scroll across the tilted card plane (cards glide in/out of center) → `3d-page-scroll` (scroll) + `viewport-change` (lateral/vertical pan form)
|
||||
- hero metric card centering (scroll/pan lands the target dead-center) → `coordinate-target-zoom` (target lands at viewport center) / `viewport-change`
|
||||
- hard-cut from the hook orb into the grid → `scale-swap-transition`
|
||||
- kinetic tagline assembled word-by-word → `kinetic-beat-slam` (one onset grid, distinct per-word entrances)
|
||||
- slow continuous zoom-in + push-THROUGH the instruments + lateral/vertical pan between cards + push-in-then-out bookend → `multi-phase-camera` (see camera modifier)
|
||||
- soft accent glow BLOOM behind the hero card → `ambient-glow-bloom` (un-triggered soft glow/bloom behind the static hero element — distinct from `press-release-spring`'s press-triggered glow and `asr-keyword-glow`'s word-timed envelope)
|
||||
- selective dim/blur of a SUBSET of grid items (focus-falloff on the non-highlighted cards) → `depth-of-field-blur` (selective per-element blur/dim to spotlight the highlighted cards — the same focus-falloff rule used in `constellation-hub`)
|
||||
|
||||
**camera modifier**: The camera is the through-line that traverses the data instruments — one camera wrapper sequenced by `multi-phase-camera`, with each stop targeted via `coordinate-target-zoom` onto the focal instrument/card.
|
||||
|
||||
- Problem — push-THROUGH: a slow continuous zoom-in (drift overlay) plus a fast PUSH-IN straight through the center of one instrument into the next (`multi-phase-camera`, Steady-push pattern), then a lateral/vertical PAN to the final card. Z-depth push-through is the signature (distinguishes it from a flat pan-tour).
|
||||
- Product_Intro — scroll-to-hero + bookend push: a hands-off directional SCROLL across the tilted card plane (`3d-page-scroll` scroll / `viewport-change` pan) that lands the hero card center, then a gentle push-in that PEAKS and eases BACK OUT to a wider settle (`multi-phase-camera`, Bookend-pull pattern). No cursor, no clicks — the camera does the navigating.
|
||||
@@ -0,0 +1,53 @@
|
||||
# device-surface-showcase — Device / Surface Showcase
|
||||
|
||||
**intent**: A product surface — a device mockup or a floating browser/app window — is the hero held in frame while its screens cycle through a real flow, showcased by a camera move that ranges from a static hold to a continuous 3D push.
|
||||
|
||||
**roles served**
|
||||
|
||||
- Key*Feature (from key-feature-device-screen-tour, key-feature-floating-window-scroll, key-feature-3d-device-hand-demo): show a feature being \_experienced inside its real interface* — the surface houses the action and its screens advance through a flow, rather than enumerating tiles or chasing a cursor across a workflow. (Note: all three drafts are Key_Feature; this blueprint is role-narrow but mechanic-rich — variants differ by MECHANIC, not role.)
|
||||
- Key_Feature (from demo-page-scroll-spotlight): the floating-window push-scroll variant carried to a spotlight climax — a real webpage rendered as a tilted 3D card coasts in (power2, like a phone held up — no spring), header keywords flare on a karaoke glow as the VO names them, the page rolls to the demoed section, and one element LIFTS off the surface (translateZ + scale) under a radial spotlight that dims the rest.
|
||||
|
||||
**duration**: 5–9.6s (page-scroll-spotlight 5–9s · floating-window 7.8s · 3d-hand 7.9s · device-tour 9.6s)
|
||||
|
||||
**shot structure**
|
||||
One product surface — a `[device mockup]` or a `[floating browser/app window]` — is the persistent hero on a `[styled backdrop: gradient / radial / stylized 3D void]`; its `[screens/sections]` cycle through a real `[product flow]` while a showcase camera (static-hold, push-in→zoom-out, or one continuous push) presents it. Each screen state holds ~1.0–1.5s.
|
||||
|
||||
- Scene 1 (0.0–~1.5s): The surface ESTABLISHES — it `[slides in from an edge / drifts in from a tilt / dissolves from a full-frame title card]` and settles, with a `[accent shape or backdrop]` resolving behind it; the first `[screen]` is visible. The showcase camera begins (see variants).
|
||||
- Scene 2 (~1.5–~Xs): The surface is OPERATED on its own face — a `[tap/select/scroll]` triggers the first screen advance: old content `[pushes out / scrolls up]`, new `[screen/section]` `[pulls up / pushes in from the side]`; concurrently a `[label / header word / side headline]` updates. The camera continues its move.
|
||||
- Scene 3+ (~Xs–end, repeat for `[2–4 screen beats]`): The surface ADVANCES through successive `[screens/sections]`, each a discrete swap or scroll synced to the surface's flow, while the secondary copy `[swaps out-up / in-up]` or stays marked to hold reading position. HOLDS on the final `[screen]` (or, for one variant, blooms out — see variant).
|
||||
|
||||
- Variant — static-tour (key-feature-device-screen-tour, 9.6s): a `[device mockup]` slides in from off-screen and settles (ease-out); an `[accent-color shape]` scales up behind it (spring overshoot). Camera STAYS STATIC the entire clip — all motion is element/UI-level: a tap COMPRESSES a button (95%→100%), the UI scrolls/transitions to the next view (old pushes out, new pulls up), and a `[side headline]` SWAPS beside the device (old slides up + fades, new slides up + in) per screen. Holds on the final screen. No camera move, no cursor.
|
||||
- Variant — floating-window (key-feature-floating-window-scroll, 7.8s): OPENS on a full-frame `[title card]` (a small `[icon]` draws in at center, `[feature name]` below; holds ~2s), which DISSOLVES to a `[macOS-style browser/app window]` floating on a `[vivid gradient]` (traffic-lights + `[URL pill]` + tabs; left nav, central content, right `[sidebar]`). Camera PUSHES IN on a `[target region/sidebar]` (active item highlighted `[accent]`, a cursor drifts down the list), then ZOOMS BACK OUT to re-frame the whole window while the content SCROLLS through `[sections]`; the `[highlighted item]` stays marked. One push-in→zoom-out arc, gated by the title-card opener.
|
||||
- Variant — 3d-hand (key-feature-3d-device-hand-demo, 7.9s): FULLY 3D — a `[3D device]` drifts in a `[stylized 3D void / bloom + particles]`, opening tilted and self-rotating to face the lens nearly flat as ONE CONTINUOUS forward camera push begins (no cuts). A glossy `[3D hand]` rises from the bottom-foreground and GESTURE-DRIVES the surface: it swipes to scroll a `[picker/sidebar panel]` of `[option cards]` and taps `[option]` (while a `[header word]` letter-flips in place); the selection APPLIES — a `[new layout]` grows from center to fill the device face, nav flips, a `[marquee]` scrolls horizontally; the hand swipes again to scroll the page upward through `[sections]`, then drifts out. The camera never stops pushing; the bright device face keeps growing toward the lens until it BLOOMS into a `[light]` wash — a zoom-through "portal" exit that fills the frame.
|
||||
|
||||
**motion vocabulary**
|
||||
surface establish (edge slide-in + settle / tilt drift-in + self-rotate-to-camera / title-card dissolve); accent shape spring behind surface; element-level screen-cycling (scroll-swap, push-in-from-side, scale-swap); button tap-compress; staggered side-headline reveal + copy swap (out-up / in-up); in-place header-word letter-flip; floating browser-window-on-gradient idle float; full-frame title-card opener (icon draw-in + label); camera push-IN on a region; camera zoom-OUT re-frame; content scroll-through; one continuous 3D camera-follow push (no cuts); 3D device drift + self-rotate; stylized-environment bloom/particles; 3D-hand entrance + swipe-scroll + tap (gesture-driven); picker-panel slide-in; template-apply grow-from-center; horizontal marquee scroll; gesture-driven page scroll; zoom-through bloom/portal exit; static-hold (no camera) as the floor of the camera range.
|
||||
|
||||
**rule mapping** (per motion verb → backing rule, or flagged special)
|
||||
|
||||
- screen-cycling — UI scrolls/sections scroll inside the surface (device-tour, floating-window scroll, 3d-hand page scroll) → `3d-page-scroll` (webpage/app as a tilted card whose content `translateY`-scrolls to sections; primary mechanic for the surface's screen flow)
|
||||
- floating-window establish + the surface presented as a tilted/floating UI card → `3d-page-scroll` (the tilt/perspective framing) + `css-3d-transforms` (perspective/`translateZ` depth)
|
||||
- screen / side-copy state swaps (discrete screen states; side headline content swapping per beat) → `discrete-text-sequence`
|
||||
- side-headline reveal (staggered fade + slide-up) → `discrete-text-sequence`
|
||||
- in-place header-word letter-flip (3d-hand) → `hacker-flip-3d`
|
||||
- screen swap as a coordinated shrink-out / pop-in between two screen states → `scale-swap-transition`
|
||||
- template-apply "new layout grows from center to fill the face" (3d-hand) → `center-outward-expansion` (clustered-at-center → expand to fill)
|
||||
- the surface morphing between states / title-card→window dissolve as the eye-anchor transition → `card-morph-anchor`
|
||||
- button tap-compress (95%→100% press feedback) → `press-release-spring` (or `physics-press-reaction` for a heavier press)
|
||||
- floating-window cursor click on the highlighted list item → `cursor-click-ripple`
|
||||
- accent-highlight pop on the active sidebar/list item → `asr-keyword-glow` (accent glow on the focused item)
|
||||
- drifting cursor down the sidebar list (floating-window) → `camera-cursor-tracking` (flat-cursor drift; pairs with the push-in)
|
||||
- floating browser-window idle float / 3D device drift-breathe → `sine-wave-loop`
|
||||
- 3D device drift + self-rotate-to-camera + perspective depth (3d-hand) → `css-3d-transforms` (CSS-3D) **or** `3d.md` technique (true Three.js/R3F device); see camera modifier
|
||||
- horizontal `[marquee]` scroll (3d-hand) → `viewport-change` (PAN mode on the marquee strip) — _thin fit; a literal CSS-marquee/translateX loop is closer to a `gsap-effects`/CSS recipe than a named motion rule_
|
||||
- 3D-hand entrance + swipe + tap as the interaction DRIVER (gesture input that scrolls/selects) → **flagged special — needs a heavier capability beyond the rule library (R3F/Three.js + WebGL), NOT a motion-shape rule.** The 3D hand model + WebGL bloom have a _technique_ backing (`3d.md` — R3F, `useGLTF` HandModel, `--gl=swiftshader` for the shader/bloom), but no motion-shape rule models a 3D hand as the swipe-to-scroll / tap-to-select gesture protocol. `context-sensitive-cursor` / `camera-cursor-tracking` only model a flat typing/pointer cursor, not a 3D gesturing hand.
|
||||
- zoom-through bloom / portal exit (3d-hand) → **flagged special — needs a heavier capability beyond the rule library (WebGL), NOT a named transition rule.** Capability is `techniques.md` → WebGL shader (via `3d.md` headless WebGL: `--gl=swiftshader --concurrency=1`), but no named transition rule covers a bloom/portal fly-through.
|
||||
|
||||
**camera modifier**: The showcase camera spans a RANGE keyed by variant, all on a single content-wrapping virtual camera (`viewport-change`):
|
||||
|
||||
- static-tour → NO camera move (`viewport-change` held at scale 1, or omitted); all motion is element-level. This is the floor of the range and what distinguishes the device-tour from the rest.
|
||||
- floating-window → a two-phase push-in → zoom-out arc → `multi-phase-camera` (e.g. dramatic-reveal 1.1→1.0→0.95 feel): push IN on the `[sidebar/region]` via `coordinate-target-zoom` (off-center target = scale + counter-translate), then `multi-phase-camera` zooms back OUT to re-frame the whole window while content scrolls.
|
||||
- 3d-hand → ONE continuous forward push (no cuts) → `multi-phase-camera` in steady-push mode (1.0→1.03→1.06… plus its sine micro-drift) layered over `css-3d-transforms`/`3d.md` so the device self-rotates-to-lens during the push; the push runs unbroken into the bloom/portal exit (exit itself is the WebGL-shader flagged special above).
|
||||
Across all three: `viewport-change` is the base virtual-camera primitive; `multi-phase-camera` sequences the push/zoom phases (and supplies the always-on micro-drift that keeps even the "static" tour from feeling dead); `coordinate-target-zoom` aims the push at off-center screen detail.
|
||||
|
||||
**Overflow (pan/scroll surfaces — required for a clean `inspect`):** a panned or scrolled surface deliberately moves content PAST the edges of its framing card. Clip it at the card (`overflow: hidden` on the card/window) AND mark the moving inner layer (the `.world` / surface wrapper holding the screenshot + any markers/labels) with `data-layout-allow-overflow` — otherwise `inspect` reports `text_box_overflow` / `container_overflow` ERRORS for the parts that scroll off (e.g. a marker label panned off the left edge). The card clips them visually; the attribute tells `inspect` it's intentional, not a layout bug.
|
||||
@@ -0,0 +1,66 @@
|
||||
# grid-card-assemble — Grid / Card Assemble
|
||||
|
||||
**intent**: N items (tiles / cards / logos / list-lines) self-assemble in a staggered cascade into a grid or vertical list and hold — a "look how much / who / what it does" beat that enumerates breadth at once; an optional camera zoom-OUT pulls back to reveal the assembled array sitting inside a vaster whole.
|
||||
|
||||
**roles served**
|
||||
|
||||
- Key_Feature (from key-feature-card-grid-assemble): a grid of labeled feature tiles/pills (icon + label) cascades one-by-one into a 2-col-brick / 3×3 grid, then holds near-static with a slow push-in — enumerate many capabilities, no live UI, no cursor.
|
||||
- Key_Feature (from key-feature-glass-card-camera-reveal): open TIGHT on 2–3 glowing icons; a camera zoom-OUT unfolds a row of glassmorphism cards that grow from behind the icons (icons shrink to card headers), center card scales forward, the group floats, then sweeps out — a "pillars revealed at once" reveal variant of the same assemble shape.
|
||||
- Benefits (from benefits-vertical-list): short value phrases populate a single vertical list ~1 item/sec, co-resident and accumulating; each line enters via a spring marker-pop + check-draw + pill mask-wipe, OR the whole stack snaps up one slot per beat (slot-machine) so the newest lands in the bright focal slot.
|
||||
- Social_Proof (from social-proof-logo-grid-zoom-out): a wall of partner/app logos builds into a center grid (whole-enter / randomized pop-in / column slide-up), an optional headline + accent-gradient proof-number fills in above, then a continuous camera zoom-OUT shrinks the array to reveal a vast ecosystem; optional fixed HUD/viewfinder brackets; optional grid slide-up fly-out exit.
|
||||
|
||||
**duration**: 3.0–10.5s (Social_Proof 3.0–6s · Key_Feature grid 5.8–7.3s · Key_Feature glass-card 6.5s · Benefits list 6.5–10.5s, scaling ~1 item/sec with count)
|
||||
|
||||
**shot structure** (consolidated template — concrete motion verbs, [slots])
|
||||
|
||||
- **Scene 1 (0.0–~1.0s) — open + first arrivals.** On a `[gradient / radial / dark background]` (optional `[dot-grid / drifting-watermark]` texture), an empty `[grid or list region]` is established and items begin to ASSEMBLE in a quick staggered cascade (~0.04–0.08s gap; list pacing ~1 item/sec). Each `[item: feature tile / pill / logo tile / benefit line]` fades + slides/scales a short distance directly into its slot (low drama — no scatter, no big bounce; spring overshoot reserved for accent markers). Camera static. An opening `[headline / hook]` may fill in line-by-line above the array, with any `[proof number]` counting up in an `[accent gradient]`.
|
||||
- **Scene 2 (~1.0s–~Xs) — array resolves + holds.** Remaining items finish arriving; layout resolves into the final `[2-col-brick / 3×3 grid / dense mosaic / stacked list]`. The completed array HOLDS, alive but resting: a gentle continuous parallax/sine FLOAT on the tiles and/or a slow camera push-in (faint scale-up). Optional `[accent-color]` glow TRAVELS across/behind the tiles.
|
||||
- **Scene 3 (~Xs–end) — settle / reveal / exit.** Everything settles and holds to the end, OR the optional camera modifier runs (see below), OR a `[closing line / CTA]` book-ends the array.
|
||||
|
||||
Variants (where roles diverge from the template):
|
||||
|
||||
- **Variant — Key_Feature grid**: items are labeled `[icon + feature-label]` tiles/pills assembling into a 2-col-brick / 3×3 grid; near-static hold with slow push-in + optional traveling-glow sweep; headline book-ends (`[hook]` → `[CTA]`). No camera reveal.
|
||||
- **Variant — Key_Feature glass-card-reveal**: the assemble is CAMERA-DRIVEN, not element-stagger. Open tight on `[2–3 glowing icons]`; camera zoom-OUT grows `[N]` glass cards out from behind the icons (icons shrink ~50% to become card headers), `[center card]` scales ~105% and moves forward to overlap the sides (quick spring); cards hold side-by-side with continuous parallax float; exit = fast motion-blur SWEEP slides the cards off-frame.
|
||||
- **Variant — Benefits vertical-list**: a single vertical `[benefit-line]` stack, ~1 item/sec, two sub-modes — (a) BUILD: each line stays fully lit; entry = `[marker]` spring-pop + `[check/icon]` draw-in + `[pill]` mask-wipe of the text; (b) SNAP: the whole stack steps up one slot per beat (~0.1s eased) so the newest line lands in the bright focal slot and lines leaving it dim by position. Static camera; optional perpetual `[decorative orbit/disc]` on the opposite side. No camera reveal.
|
||||
- **Variant — Social_Proof logo-wall-zoom-out**: intro beat (`[trusted-by headline]` card OR a `[product screenshot]`) crossfades/cuts to a center logo grid that builds (whole-enter / randomized pop-in / column slide-up); a continuous camera zoom-OUT then shrinks the whole grid toward center to reveal a vast ecosystem and holds; optional fixed HUD/viewfinder brackets; optional exit = whole grid SLIDES UP and flies out through the top.
|
||||
|
||||
**motion vocabulary**: item stagger-assemble (fade + short slide/scale into slot) · brick/grid/list layout resolve · randomized pop-in · column slide-up · vertical-list step (slot-machine snap-and-hold) · spring-overshoot marker pop · check/icon draw-in · pill/label mask-wipe reveal · dim-by-position de-emphasis · line-by-line headline fill · accent-gradient number count-up · near-static hold · gentle parallax/sine float on hold · slow camera push-in · camera zoom-OUT reveal (continuous OR phased pull-back) · cards-grow-from-behind-icons · icon-shrink-to-header · center-card scale-up + forward overlap (spring) · traveling-glow sweep · fixed HUD/viewfinder brackets · motion-blur slide-out sweep (exit) · grid slide-up fly-out (exit) · book-end headline fade · perpetual decorative orbit/loop.
|
||||
|
||||
**rule mapping** (motion verb → `rule-id`)
|
||||
|
||||
- item stagger-assemble into slot → `center-outward-expansion` (per-item stagger + short-path slide variant; for a wall too dense for a true center burst, use it in its "starting partially-spread"/direct-into-slot form — see merge tension)
|
||||
- brick/grid/list layout resolve → `center-outward-expansion` (target positions = final layout slots)
|
||||
- randomized pop-in stagger → `gsap-effects` (stagger recipe; randomized `from`/order)
|
||||
- column slide-up into grid → `gsap-effects` (per-column staggered slide-up)
|
||||
- vertical-list step / slot-machine snap-and-hold → `vertical-spring-ticker` (STEPS = number of line advances)
|
||||
- spring-overshoot marker pop → `spring-pop-entrance` (back.out spring) — also `gsap-effects` for the staggered pop chain
|
||||
- check / icon draw-in inside marker → `svg-path-draw`
|
||||
- live line-art icon in a tile (internal parts) → `svg-icon-enrichment`
|
||||
- pill / label mask-wipe text reveal → `techniques.md` (clip-path reveal)
|
||||
- dim-by-position de-emphasis → `gsap-effects` (per-line opacity by slot position; no dedicated rule)
|
||||
- line-by-line headline fill → `discrete-text-sequence`
|
||||
- accent-gradient proof number count-up → `counting-dynamic-scale`
|
||||
- gentle parallax / sine float on hold → `sine-wave-loop` (apply the concurrent-elements amplitude `/√N` rule for a held grid)
|
||||
- slow camera push-in → `multi-phase-camera` (steady-push phase pattern)
|
||||
- center-card scale-up + forward overlap → `spring-pop-entrance` (the quick spring) + `techniques.md` CSS-3D (z-depth overlap)
|
||||
- cards-grow-from-behind-icons / icon-shrink-to-header → driven by the camera reveal (`multi-phase-camera`) — the grow/shrink are scale tweens chorded to the pull-back phase; no separate rule
|
||||
- fixed HUD / viewfinder brackets → `ai-tracking-box` (static-bracket variant — overlay frame, not tracking)
|
||||
- book-end headline fade → `discrete-text-sequence` (or `gsap-effects` fade)
|
||||
- perpetual decorative orbit / disc / loop → `sine-wave-loop` (or `orbit-3d-entry` if it's an orbiting badge ring)
|
||||
- traveling-glow sweep across/behind tiles → `ambient-glow-bloom` (one-pass traveling glow sweep across the tiles)
|
||||
- motion-blur slide-out sweep (glass-card exit) → `motion-blur-streak` (directional velocity blur on the fast sweep that carries the cards off-frame)
|
||||
- grid slide-up fly-out exit → `gsap-effects` (plain staggered translate-off-frame; no dedicated rule needed — a basic exit tween, not a missing capability)
|
||||
|
||||
**camera modifier — zoom-OUT reveal** (optional; the role-defining move for the glass-card and logo-wall variants): a camera wrapper around the whole array scales DOWN over the hold, revealing the assembled grid/cards sitting inside a larger environment (ecosystem scale, or a row of cards unfolding from tight icons).
|
||||
|
||||
- Continuous single-pass zoom-out (Social_Proof ecosystem pull-back) → `viewport-change` (one wrapper, `cam.scale` ↓ via onUpdate — single source of truth)
|
||||
- Phased pull-back → focus → settle, with built-in drift (Key_Feature tight-icons → cards-unfold) → `multi-phase-camera` (use the "Dramatic reveal: push → neutral → pull" / pull-back phase pattern; grow/shrink of cards chords to the pull-back phase)
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
BLUEPRINT: grid-card-assemble — serves Key_Feature, Benefits, Social_Proof (folded 4 drafts)
|
||||
RULE GAPS: none — traveling-glow sweep → ambient-glow-bloom; motion-blur slide-out sweep (exit) → motion-blur-streak; grid slide-up fly-out (exit) → gsap-effects (plain translate)
|
||||
```
|
||||
|
||||
Merge tension: `center-outward-expansion` (the natural backing for stagger-assemble) caps cleanly at 3–8 items and explicitly warns 8+ causes mid-flight overlap chaos — but a Social_Proof logo wall is deliberately dense (12+ tiles), so for that variant the items must NOT burst from a shared center; they slide a short distance directly into their own slot (the rule's "starting partially-spread"/short-path form, or a `gsap-effects` per-item stagger), which the consolidated Scene-1 verb already specifies as "short distance directly into its slot."
|
||||
@@ -0,0 +1,78 @@
|
||||
# kinetic-type-beats — Kinetic-Type Beats
|
||||
|
||||
**intent**: A flat, centered, bold-type shot where the motion IS the word/phrase changing — the line either swaps tokens in place by hard cut, or builds a statement across full-screen beats (each with its own move) that lands a spring-pop payoff.
|
||||
|
||||
**roles served**
|
||||
|
||||
- Hook (from `hook-kinetic-type-flash`): when one stationary line lands a punchy rhetorical question or "you keep doing X" callout and the in-place token swap itself is the joke.
|
||||
- Hook (from `hook-kinetic-type-escalation`): when ONE statement should escalate across distinct full-screen beats (each a different move) and punctuate on a spring-pop payoff element — a rising-intensity / "transform X into Y" opener.
|
||||
- Problem (from `problem-kinetic-type-beats`): when the script is 3–5 short pain statements (or a "what-if?" framing) that should each land alone, on a bare canvas, before the next replaces it — no product visible yet.
|
||||
- Product_Intro (from `product-intro-kinetic-type-namedrop`): when the hook IS the words — hard-cut through "Introducing…" / tagline / value beats and resolve on the brand name or logo.
|
||||
- Benefits (from `benefits-kinetic-type`): when "what you get" is a rapid-fire staccato montage — 8–12 short value phrases, each flashing and clearing before the next at high tempo.
|
||||
- CTA (from `cta-kinetic-type`): when the sign-off is a punchy closing line (or a short stack of value lines) that snaps/fades in beat-by-beat and lands on the brand lockup or URL — no spatial set, no clicked button.
|
||||
- Brand_Outro (from `brand-outro-kinetic-type-resolve`): when the close is a rapid center-channel barrage of single-word verbs asserting breadth, resolving on the brand's one defining word (motion-is-the-message, no logo lockup).
|
||||
|
||||
**duration**: 3.4–12s (Benefits fastest ~3.5–4s at 8–12 sub-0.5s beats; Brand_Outro ~3.6s; Problem longest 7–12s; CTA spans 3.6–11.7s with beat count)
|
||||
|
||||
**shot structure** (flat, fixed center anchor; bold sans-serif text on a solid `[bg color]`; type/tokens are the only subject; camera locked unless a modifier is noted; two folded sub-shapes — **(A) fixed-line token swap** and **(B) multi-beat statement build**)
|
||||
|
||||
- **Scene 1 (0.0–~1.0s) — first beat lands.** Solid `[bg color]` field. Bold `[type color]` text arrives dead-center via ONE entrance: type-on character-by-character with a trailing blinking caret, OR a hard-cut FLASH-in (no fade/slide), OR a per-word staggered fade/blur, OR an oversized word that smoothly SCALES DOWN to a small centered word. An optional `[accent color]` move plays on the key word(s): a left→right drawn underline / strike-through, a small particle/dot burst from behind the text, or a `[accent color]` selection-box framing the word.
|
||||
- _Variant — Hook (flash)_: just the fixed `[hook line]` (or its first word) parks at center; no escalation move.
|
||||
- _Variant — Hook (escalation)_: `[beat 1 text]` arrives big and scale-downs to centered, OR sits over a glowing `[motif]` with a slow camera push-in (see camera modifier); ends on a hard cut.
|
||||
- _Variant — Problem_: centered `[pain line 1]` reveals in chunks across one or two lines with its `[accent]` underline / particle burst.
|
||||
- _Variant — Product_Intro_: bold `[hook word, e.g. "Introducing"]` enters with a typographic accent (split-and-slide apart, drawn underline, or `[accent]` selection-box).
|
||||
- _Variant — Brand_Outro_: optional single-frame flash of `[product UI / hero asset]` precedes the verb channel, then `[verb 1]` hard-cuts in centered.
|
||||
|
||||
- **Scene 2..N — beats replace each other in place (the engine).** The center anchor advances one beat at a time; nothing from the prior beat lingers. Choose the swap mechanism by sub-shape:
|
||||
- **Sub-shape A (fixed-line token swap)**: the line stays fixed and only the variable slot changes by an instant hard CUT (no roll/scroll/blur) — `[token A]` → `[token B]` → `[token C]` — OR the final word(s) backspace out and a new word retypes (`[word A]` → `[word B]`). The rest of the line holds.
|
||||
- **Sub-shape B (multi-beat statement build)**: each full-screen beat hard-cuts to a NEW background/line, and each gets its own distinct entrance/exit MOVE — springy scale-in/scale-out overshoot, 3D letter-tumble (glyphs scatter into a rotating depth cloud, then reassemble into the next phrase), motion-blur fly-in that resolves sharp at center, prior text accelerates/zooms past the camera while fading, letter-spacing collapse, or a bottom-up masked slide. Background may hard-flip `[bg A]`↔`[bg B]` on selected beats with `[type color]` inverting to stay legible.
|
||||
- _Variant — Hook (escalation)_: beat 2 `[beat 2 text]` (more emphatic) snaps in; beat 3 `[beat 3 text]` (climax) holds, then a transition-out move on the type itself — a Z-dolly forward THROUGH an oversized glyph, OR a per-word karaoke highlight sweep lighting words left→right.
|
||||
- _Variant — Problem_: each `[pain line k]` enters by chunk-reveal or motion-blur fly-in as the prior blurs/zooms off; an optional `[accent color]` interstitial word ("[what-if hook]") scales up from center, holds, then zooms past the camera and fades.
|
||||
- _Variant — Product_Intro_: each `[tagline phrase]` is a hard-cut/push-through inverted-text beat with its own one-shot accent (strike-through, slider/toggle shapes sliding in, or a bg-invert cycle white→`[accent]`→black flipping fg/bg).
|
||||
- _Variant — Benefits_: high tempo (~0.4s/beat) — each `[benefit phrase]` pops via springy scale-in/out or 3D letter-tumble; multiple bg light↔dark flips across the run with text-color invert.
|
||||
- _Variant — CTA_: each `[value line]` → `[value line]` → `[CTA verb line]` clears by hard cut / zoom-blur cut through near-black / fade-out, then the next pops/fades/slides in. Optional `[accent motif]` draws on behind (rising line-graph trim-path, thin wireframe guides, gutter geometry tiles).
|
||||
- _Variant — Brand_Outro_: a centered single `[verb / keyword]` HARD-CUTS to the next at a steady ~0.2s cadence (no fade/scale) over a continuous moving field (see camera modifier).
|
||||
|
||||
- **Scene N (final beat → end) — resolve and HOLD.** The last beat lands and holds to the end (settle only, no further scale-out). Resolution diverges by role:
|
||||
- _Variant — Hook (flash)_: last token swap lands and holds; optional tiny punctuation/emphasis snap (`?` → `?!`, or fill snaps to `[accent color]`).
|
||||
- _Variant — Hook (escalation)_: resolve on `[payoff bg]` — a `[payoff element]` (colored square / heart-eyes reaction emoji) SPRING-POPS in center; small `[accent motes]` drift outward; subtle settle.
|
||||
- _Variant — Problem_: final `[pain line]` reveals (left→right swipe with leading-edge blur, OR letters explode radially then the resolving line fades up); holds the pain on screen.
|
||||
- _Variant — Product_Intro_: resolve on the brand — `[logo mark]` / `[wordmark]` pops in centered (optional sting: liquid/ink splash, blob backing), OR the final value word holds inside an expanding-iris `[accent]` circle that scales to fill frame and hard-cuts the closing word through it.
|
||||
- _Variant — Benefits_: the last `[benefit phrase]` arrives (optionally on the inverted bg) and SETTLES — does not scale/tumble back out.
|
||||
- _Variant — CTA_: land on the lockup — `[logo mark]` SCALES UP small→full and holds, OR a `[logo]`/`[url]` builds segment-by-segment beside its icon. End-card holds dead static.
|
||||
- _Variant — Brand_Outro_: hard cut to the `[resolve word / brand keyword]` (longest, still centered); HOLDS ~0.5s while the background field keeps moving.
|
||||
|
||||
**motion vocabulary**: hard-cut / flash word swaps; in-place token cycle (instant cut, no roll/scroll/blur); type-on with trailing blinking caret; backspace-and-retype; per-word staggered fade/blur reveal; big→small scale-down; springy scale-in/scale-out overshoot; 3D letter-tumble scatter-and-reassemble; motion-blur fly-in / blur-off; prior text zoom-through-camera; letter-spacing collapse; bottom-up masked slide; drawn-on `[accent]` underline / strike-through; particle/dot burst from text; `[accent]` selection-box frame; bg-invert hard-flip with text-color invert; karaoke per-word highlight sweep; radial letter-explode; expanding-iris circle wipe-to-next; final spring-pop payoff element (square / emoji / logo mark); drifting `[accent]` motes / ambient shapes; segment-by-segment URL/wordmark build; final-token punctuation snap; settle-and-hold.
|
||||
|
||||
**rule mapping**
|
||||
|
||||
- hard-cut / flash word swaps, in-place token cycle, whole-line state swaps at time thresholds → `discrete-text-sequence`
|
||||
- type-on character-by-character + blinking trailing caret → `discrete-text-sequence` (text/typing state progression) + `context-sensitive-cursor` (caret blink/color-switch)
|
||||
- backspace-and-retype final word(s) → `discrete-text-sequence` (typos/holds/backspace is explicitly in-scope)
|
||||
- one short distinct phrase per beat / script-driven phrase windows / word-by-word tagline assembly → `dynamic-content-sequencing`
|
||||
- percussive per-beat phrase entrances on a shared beat array (distinct entrance per phrase, steady cadence) → `kinetic-beat-slam` (best fit for the multi-beat statement-build engine and the ~0.2s Brand_Outro verb march)
|
||||
- per-word staggered fade/blur reveal → `kinetic-beat-slam` (per-phrase/per-word distinct entrances); the soft-focus blur component → `depth-of-field-blur` (selective-focus blur on the off-focus words)
|
||||
- big→small scale-down on a word; springy scale-in/scale-out overshoot → `spring-pop-entrance` (spring pop/settle) backed by `gsap-effects` for the plain scale tween
|
||||
- 3D letter-tumble scatter-into-depth-cloud then reassemble → `depth-scatter-assemble` (glyphs scatter into a 3D depth cloud and reassemble into the next phrase; combine w/ `3d-text-depth-layers` for the extruded read, or `hacker-flip-3d` for an in-place per-char flip flavor)
|
||||
- karaoke per-word highlight sweep synced across words → `asr-keyword-glow` (keyword glow+scale on a synced rail) OR `css-marker-patterns` (highlight sweep) — choose ASR-driven vs. static-timeline sweep
|
||||
- drawn-on `[accent]` underline / strike-through / loop / scribble under key word → `css-marker-patterns` (highlight sweep / circle / burst / scribble / sketchout)
|
||||
- particle/dot burst from behind text → `css-marker-patterns` (burst) backed by `gsap-effects`
|
||||
- `[accent]` selection-box frame around a word → `css-marker-patterns` (circle/box marker) + `gsap-effects`
|
||||
- bg-invert hard-flip (light↔dark / white→accent→black) with text-color invert → `discrete-text-sequence` (whole-text/state swap covers the synchronized fg/bg state change)
|
||||
- letter-spacing collapse; bottom-up masked slide → `gsap-effects` (tween letter-spacing / masked translate) + techniques: per-word kinetic typography / clip-path reveal
|
||||
- expanding-iris circle wipe that morphs the current word into the next at the same center → `scale-swap-transition` (morph two elements at same center)
|
||||
- final spring-pop payoff element (colored square / reaction emoji / logo mark) → `spring-pop-entrance` (or `physics-press-reaction` for a weightier pop)
|
||||
- drifting `[accent]` motes / ambient shapes / soft drifting gradient field beneath the type → `sine-wave-loop` (idle drift loop)
|
||||
- segment-by-segment URL / wordmark build beside its icon → `discrete-text-sequence` (segment-by-segment state reveal) or `dynamic-content-sequencing`
|
||||
- final-token punctuation / emphasis snap (`?`→`?!`, fill→accent) → `discrete-text-sequence`
|
||||
- settle-and-hold final frame → `spring-pop-entrance` (settle phase) / static hold (no rule needed)
|
||||
- motion-blur fly-in / blur-off / zoom-through-camera streak on type → `motion-blur-streak` (directional velocity blur on a fast fly-in / zoom-through; the heavy motion-blur smear resolves sharp at center)
|
||||
- radial letter-explode (glyphs explode outward radially then resolve) → `depth-scatter-assemble` (radial per-letter explode-and-resolve is in scope alongside the depth-cloud scatter)
|
||||
- 3D letter-tumble depth-cloud scatter-and-reassemble → `depth-scatter-assemble` (free tumbling depth-cloud that flies out and snaps back into the next phrase)
|
||||
|
||||
**camera modifier** (optional, layered over the flat shot; most variants are camera-locked)
|
||||
|
||||
- Slow continuous global zoom-in / uniform push-in running underneath the whole sequence (Problem, Brand_Outro) → `multi-phase-camera` (push phase) — gives parallax between the fixed type and a moving background field.
|
||||
- Camera dolly/zoom forward THROUGH an oversized glyph along Z as a beat transition-out (Hook escalation, Product_Intro push-through) → `coordinate-target-zoom` (target the glyph center) or `multi-phase-camera` (push).
|
||||
- Slow push-in on Scene 1 over a glowing `[motif]` (Hook escalation) → `multi-phase-camera` (push) or `coordinate-target-zoom`.
|
||||
- Note: the in-place token swap (sub-shape A) and most Benefits/Hook-flash/CTA variants are fully camera-static — the swap is the only motion.
|
||||
@@ -0,0 +1,69 @@
|
||||
# logo-assemble-lockup — Logo Assemble → Lockup
|
||||
|
||||
**intent**: A brand mark / wordmark builds itself from parts (elements assemble or orbit in, letters cascade, an outline draws on, or a camera pushes through negative space) and resolves into a centered logo lockup — optionally extended into a final URL / CTA.
|
||||
|
||||
**roles served**
|
||||
|
||||
- Product_Intro (from product-intro-logo-system-assemble): A wordless, premium brand STING — an abstract system of elements pulses / grows / orbits and assembles around a FIXED central logo, carried by one cinematic camera tilt; no copy, no UI.
|
||||
- CTA (from cta-camera-push-lockup): The logo build is a LEAD-IN to the final ask — a 3D mark assembles + wordmark cascades, then a fast camera PUSH-THROUGH the mark's negative space streaks giant CTA letters past the lens and resolves on a `[url]` / `[CTA verb]` lockup.
|
||||
- CTA (from cta-button-wordmark-build): The "draws-its-own-outline → wordmark-builds-letter-by-letter" sub-shape — a `[CTA button]` pill strokes its own glowing border, a diagonal-band WIPE flips the frame, and the `[wordmark]` types in beside a slash to land the lockup. Camera static.
|
||||
- Brand_Outro (from brand-outro-assemble-logo-lockup): The closing mark — a formation of `[feature pills / UI elements]` CLEARS the stage off all four edges, then on the empty frame the `[logo mark]` draws itself on stroke-by-stroke and the `[wordmark]` reveals to complete the lockup, then fades out.
|
||||
- Product_Intro (from brand-reveal-assemble-zoom): a context-then-focus reveal — a companion tagline TYPES out to set context, the hero mark pops in beside it, then the companion exits as the layout recenters and the camera pushes IN to a held close-up on the mark (wide composition narrowing to a tight focus).
|
||||
|
||||
**duration**: ~4.6–11.0s (Brand_Outro ~4.6s · brand-reveal ~5s · Product_Intro ~7s · CTA 5.4–11.0s)
|
||||
|
||||
**shot structure** (one consolidated time-coded template; `[slots]` are product-agnostic)
|
||||
|
||||
- Scene 1 — clear / ignite (0.0–~1.0s): the stage is prepared for the mark to build into.
|
||||
- _Variant — Product_Intro_: opens on a clean `[light bg]` with faint concentric guide rings under a flat top-down view; rings PULSE and expand from center; mid-beat the bg crossfades `[light]→[dark gradient: hero→secondary]`, tiny seed dots appear along the rings, and the central `[logo mark]`'s glow IGNITES (mark is present from t=0, fixed, front-facing).
|
||||
- _Variant — CTA push_: on a `[bg gradient]`, the `[logo mark]` is settling in object space (a 3D mark with thin wireframe edge-guides + a faint bracket motif behind center); a very slow continuous camera push-in may already be creeping.
|
||||
- _Variant — CTA button-build_: on a `[dark grid bg]`, a rounded `[CTA button "label"]` pill rises / scales into center (a prior headline clearing off the top); its thin border DRAWS ON as an animated glowing outline STROKE, with a small `[accent]` comet / spark icon at its left edge.
|
||||
- _Variant — Brand_Outro_: a PRE-ARRANGED formation of `[feature pills / element grid]` (each `[icon]`+`[label]`) DISPERSES — elements slide outward from their laid-out positions and fly off all four frame edges (edge-clearing drift, NOT a center-origin burst), emptying the frame onto a clean `[bg]`.
|
||||
|
||||
- Scene 2 — assemble the mark (~1.0–~Ys): the mark builds itself from parts.
|
||||
- _Variant — Product_Intro_: seed dots SCALE UP into flat `[accent]` shapes arranged on the rings; concentric bands ripple outward (tunneling feel) and the shapes begin to ORBIT / drift around the still-fixed center.
|
||||
- _Variant — CTA push_: the `[wordmark]` CASCADES out from behind the mark (letters left→right with overshoot) into the full `[brand lockup]`; the 3D mark may assemble in beats (a terminal detaches + pops as a spring dot, a part hinges-open-and-snaps-shut elastic). Optional beat: a `[cursor]` arcs in and "clicks" the wordmark, OR a frosted-glass pill holding an intermediate `[CTA line]` springs in while layered mark shells fan to the edges.
|
||||
- _Variant — CTA button-build_: a graphic WIPE flips the frame to `[contrast bg]` — a thin `[accent]` diagonal line sweeps in, swells into a full-frame diagonal BAND, then collapses to a small `[accent]` slash.
|
||||
- _Variant — Brand_Outro_: on the now-clear frame, the `[logo mark]` DRAWS ON via stroke (built arc-by-arc / segment-by-segment).
|
||||
|
||||
- Scene 3 — resolve to lockup (~Ys–end): the lockup completes and holds (Product_Intro / Brand_Outro) or is flown into / extended to a CTA (CTA variants).
|
||||
- _Variant — Product_Intro (the ONE camera move)_: the whole system smoothly TILTS from flat top-down into an angled isometric perspective (ease-in-out) with a slight zoom-out — flat shapes become luminous 3D forms, bands become glowing orbit lines, while the central `[logo mark]` does NOT tilt (stays 2D, front-facing, fixed). Camera eases to a stop; elements keep continuous orbit/drift (inner faster than outer); the mark holds its steady glow. Final settled frame.
|
||||
- _Variant — CTA push (the signature)_: a single fast CAMERA PUSH-THROUGH the mark's negative space / through the glass pill — heavy horizontal motion-blur, giant `[CTA]` letters streaking past the lens (cursor drops out). Resolves to the final lockup on a saturated `[bg]`: a `[url badge]` / `[CTA line]` revealed by a left→right WIPE carrying an `[accent]` leading edge (or a clean fade), with solid mark-shapes parallax-sliding in behind. Settles to a dead-static hold (slow zoom-out / settle).
|
||||
- _Variant — CTA button-build_: the `[wordmark]` BUILDS letter-by-letter to the right of the slash, landing on the final "`[slash] [WORDMARK]`" lockup centered on the new bg. Slow settle to static.
|
||||
- _Variant — Brand_Outro_: the `[wordmark]` reveals beside the drawn mark (slide / fade) to complete the `[lockup]`; the lockup holds, then fades to `[black / bg]`.
|
||||
|
||||
**motion vocabulary**: ring pulse / expand; background crossfade (light→dark); glow ignite; seed-dot scale-up; continuous orbit / drift (inner faster than outer); single 3D perspective tilt (flat→isometric) + slight zoom-out around a fixed 2D anchor; 3D logo assemble (part detach + spring dot, clapperboard hinge / snap, shell fan-out); wordmark cascade with overshoot (letters left→right); button pill rise / scale-in; animated stroke-outline DRAW + glow (button border AND logo mark); comet / spark accent; diagonal-band wipe (sweep → swell → collapse-to-slash); letter-by-letter wordmark build; pre-formed grid DISPERSE off all four edges; logo-mark stroke-draw (sequential arcs / segments); fast CAMERA PUSH-THROUGH with motion-blur (CTA spine); continuous slow push-in / push-out; cursor arc-in + click; parallax shape slide-in; left→right URL/badge wipe with glowing leading edge; static / fade-out end-lockup hold; optional idle breathe on the held mark.
|
||||
|
||||
**rule mapping** (per motion verb → `rules/<id>.md`)
|
||||
|
||||
- ring pulse / expand from center → `center-outward-expansion` (radiate from a shared center; reuse the 0→1 progress driver)
|
||||
- background crossfade (light→dark gradient) → plain opacity/background tween via `gsap-effects` (no dedicated rule needed)
|
||||
- glow ignite on the mark → `asr-keyword-glow` (envelope-driven glow on the brand element)
|
||||
- seed-dot scale-up into shapes → `spring-pop-entrance` (scale-in pop; alt `scale-swap-transition` if dots morph into shapes)
|
||||
- continuous orbit / drift around fixed center → `orbit-3d-entry` (flip-in then continuous elliptical orbit; center label = the fixed mark)
|
||||
- single 3D perspective tilt (flat→isometric) + slight zoom-out → `multi-phase-camera` (scripted scale phases on a scene-wrapping camera, for the zoom-out) — see camera modifier; the FLAT→ISOMETRIC plane tilt of the whole stage is a CSS-3D perspective move (`techniques.md` CSS-3D, animating the stage's `rotateX`) — no exact camera rule for the plane-tilt, approximate via CSS-3D (closest reference is `orbit-3d-entry`'s "Tilted orbit plane" variation animated over time)
|
||||
- fixed 2D anchor logo amid moving universe → no motion rule needed (static anchor; intentional — it's the absence of motion, the universe moves around it)
|
||||
- 3D logo assemble — part detach + spring dot → `spring-pop-entrance` (spring pop, `back.out` overshoot)
|
||||
- 3D logo assemble — hinge open / snap (clapperboard) → `hacker-flip-3d` (the 3D-rotate axis) + `techniques.md` CSS-3D (the elastic open-and-snap-shut hinge is an adaptation of the 3D-rotate)
|
||||
- 3D logo assemble — shell fan-out to edges → `center-outward-expansion` (run outward from the mark center)
|
||||
- wordmark cascade with overshoot (letters left→right) → recipe `gsap-effects` (per-element staggered slide) + `spring-pop-entrance` (the `back.out` overshoot per letter)
|
||||
- button pill rise / scale-in → `spring-pop-entrance` (scale-in; alt `scale-swap-transition`)
|
||||
- animated stroke-outline draw + glow (button border) → `svg-path-draw` (stroke-dashoffset draw) + `asr-keyword-glow` (the glow on the drawn stroke)
|
||||
- comet / spark accent on button → `asr-keyword-glow` (small glow accent); motion path via `techniques.md` GSAP MotionPathPlugin (#9)
|
||||
- diagonal-band wipe (sweep → swell → collapse-to-slash) → `techniques.md` clip-path reveal (#12, animate a `polygon(...)` diagonal across the frame; the swell-then-collapse-to-slash is the same clip-path reveal driven through grow→shrink keyframes)
|
||||
- letter-by-letter wordmark build → `discrete-text-sequence` (smooth-slice / per-state build); recipe `gsap-effects` (typewriter / appending words)
|
||||
- pre-formed grid disperse off all four edges → not a rule gap: a formation flying off-frame is an EXIT, and the pipeline forbids mid-video exits — the harness transition IS the exit (only the final frame may exit the stage). Treat this as transition-handled / final-frame-only rather than an in-scene motion rule. (If staged in-scene as a reveal-the-mark clear, it reuses `center-outward-expansion` run OUTWARD — center→target machinery interpolating formation→offscreen targets, out-easing.)
|
||||
- logo-mark stroke-draw (sequential arcs / segments) → `svg-path-draw` (the canonical multi-segment stagger draw)
|
||||
- wordmark slide / fade reveal beside drawn mark → `svg-path-draw` (its "brand-line fades in after stroke" tail) ; slide via `spring-pop-entrance`
|
||||
- fast camera push-through with motion-blur → `multi-phase-camera` (a hard push phase) — see camera modifier; the heavy motion-blur streak itself → `motion-blur-streak` (directional velocity blur on the fast push-through)
|
||||
- continuous slow push-in / push-out → `multi-phase-camera` (phase scale + drift)
|
||||
- cursor arc-in + click on the wordmark → `cursor-click-ripple` (move → click → ripple); arc path via `techniques.md` MotionPathPlugin (#9)
|
||||
- parallax shape slide-in behind lockup → `depth-scatter-assemble` (parallax depth slide-in of shapes at differing depths; pair with `3d-text-depth-layers` for the depth ordering)
|
||||
- left→right URL / badge wipe with glowing leading edge → `techniques.md` clip-path reveal (#12, animate `inset()` left→right); the glowing leading edge → `asr-keyword-glow`
|
||||
- static / fade-out end-lockup hold → no motion rule needed (terminal hold / opacity fade; intentional)
|
||||
- idle breathe on held mark (optional) → `sine-wave-loop` (post-settle breathing)
|
||||
|
||||
**camera modifier** (the push / tilt)
|
||||
|
||||
- **CTA push-through** (the CTA spine): a scripted hard zoom phase on a scene-wrapping camera → `multi-phase-camera` ("Steady push" / "Bookend pull" pattern; push phase = the climax). When the mark is OFF-center and the camera must fly through a specific point of negative space, combine with `coordinate-target-zoom` (outer scales, inner counter-translates so the target negative-space point lands at viewport center as scale ramps; measure the offset at setup). The signature heavy horizontal MOTION-BLUR on the streak → `motion-blur-streak` (directional velocity blur on the push); realize with a CSS `filter: blur()` / duplicated-streak layer on the camera during the push window.
|
||||
- **Product_Intro tilt** (the one cinematic move): the flat→isometric perspective tilt + slight zoom-out is a single scripted camera beat → `multi-phase-camera` (scale phase + the "Targeted zoom into off-center element" / drift machinery) for the zoom-out. `multi-phase-camera` is scale+translate+drift only, so the perspective-PLANE rotateX (flat top-down → angled isometric) of the whole stage is the CSS-3D move noted above — approximate via `techniques.md` CSS-3D, animating the stage's `rotateX` (closest reference is `orbit-3d-entry`'s "Tilted orbit plane" variation animated over time).
|
||||
@@ -0,0 +1,28 @@
|
||||
# overwhelm-surround — Overwhelm / Close-In
|
||||
|
||||
**intent**: Convey overwhelm by accumulation. Recognizable subjects assemble, density markers scatter in to amplify "look how much," then the central subject morphs into the viewer's own avatar and elements close in from ALL sides — the frame feels surrounded, not zoomed-into. The emotional arc is recognition → claustrophobia.
|
||||
|
||||
**roles served**
|
||||
|
||||
- Problem (from `problem-mockup-overwhelm`): when the problem beat must first show "too many tools / too much surface area" and then put **the viewer inside it** — a literal swap of subject (product → person) followed by a closing-in that feels invasive. Reach for it when the pain is "you're buried," not "this metric is bad" (that's `dataviz-countup`).
|
||||
|
||||
**duration**: 6–9s
|
||||
|
||||
**shot structure** (a `[bg]` canvas; recognizable surfaces first, the viewer's avatar revealed underneath, then a radial crowd)
|
||||
|
||||
- **Scene 1 (0.0–~1.6s) — recognizable assembly.** Three `[product mockups / surfaces]` assemble into something the viewer knows — staggered scale-in, the **center** one full-size, the two flanks smaller (~0.86). Each rides a low-amplitude float so they feel like live context, not a static collage. Camera static.
|
||||
- **Scene 2 (~1.6–3.0s) — density amplifies.** `[platform icons / logos]` scatter in around the mockups (staggered), used purely as **density markers** — "look how much surface area," not animated dials.
|
||||
- **Scene 3 (~3.0–4.6s) — the morph (signature move).** The CENTER mockup MORPHS: its content fades out, the container reshapes, and the viewer's `[avatar]` is revealed **underneath** — a literal swap of subject, product → person.
|
||||
- **Scene 4 (~4.6–end) — close-in.** `[task bubbles / demands]` close in from ALL sides toward the avatar (radial staggered entry). The avatar **stays put** while the bubbles invade — the claustrophobia comes from being surrounded, never from a camera push. Holds on the crowded state.
|
||||
|
||||
**motion vocabulary**: staggered scale-in assembly; resting-scale-preserving low float; density-marker icon scatter; content-fade → container-reshape → reveal-anchor-beneath morph; radial close-in entry from all compass points; held crowded end-state.
|
||||
|
||||
**rule mapping**
|
||||
|
||||
- staggered mockup + icon entries (smooth settle onto their resting scale) → `spring-pop-entrance` (smooth-settle register) backed by `gsap-effects`
|
||||
- platform icons as density markers (positions pre-baked, scale/opacity only — NOT internal-parts animation) → `svg-icon-enrichment` (its DOM contract only)
|
||||
- center mockup → avatar morph (HF forbids `width`/`height` tweens → drive the reshape on `scaleX`/`scaleY`, anchor = the avatar layer rendered beneath) → `card-morph-anchor`
|
||||
- radial bubble close-in (positions baked once via `cos`/`sin`, staggered entry) → `gsap-effects` (radial layout) + `spring-pop-entrance` (per-bubble arrival)
|
||||
- low-amplitude float on background mockups/icons → `sine-wave-loop` (low-amplitude register — subtle jitter that composes onto each element's resting scale, never a `fromTo` yoyo that re-tweens to its start)
|
||||
|
||||
**camera modifier**: camera-static — the close-in must read as the world crowding the subject, so the frame holds; a push-in would convert "surrounded" into "zoomed-into" and kill the claustrophobia.
|
||||
@@ -0,0 +1,37 @@
|
||||
# spatial-pan-stations — Spatial Pan / Stations
|
||||
|
||||
**intent**: Pre-place a sequence of labeled stations on one oversized canvas, then traverse it with a single virtual camera — repeated lateral/diagonal pans that center each station in turn and reveal a callout at every stop, landing held on a final station.
|
||||
|
||||
**roles served**
|
||||
|
||||
- Hook (from hook-pan-timeline / #1 Hook_02): a horizontal timeline of evenly-spaced milestones, left-panned beat by beat, each marker getting a spring-popped callout, landing on the present moment ("evolution / milestone walk leading up to us").
|
||||
- Problem (from problem-camera-pan-stations / #8 Problem_01): a connected web of pain "stations" linked by hand-drawn leading lines, diagonally panned station to station, ending on a tangled scribble knot ("too many disconnected steps — it's a mess").
|
||||
- Product_Intro (from concept-demo-decode-pan): a two-shot strip bridged by ONE lateral pan — shot 1 holds a static phrase whose accent word 3D-flap-DECODES (the concept lands), then the camera pans across the strip (with background parallax) into shot 2, where a cursor drives a live typing demo. Pairs this pan with `cursor-ui-demo`'s focal-locked tracked typing.
|
||||
|
||||
**duration**: 7–10s (union of Hook 8–10s, Problem ~7s, concept-demo ~7s)
|
||||
|
||||
**shot structure**
|
||||
One oversized flat canvas on a solid `[bg color]`; all stations/markers pre-placed in world space; `[accent color]` text + simple line-icons; one virtual `.world` camera pans ease-in-out between stops. Each station holds ~1.0s.
|
||||
|
||||
- Scene 1 (0.0–~1.0s): Camera opens on station 1 — `[label 1 / first step]` centered. A reveal lands on it (see variants). Camera then begins to PAN toward station 2, sliding station 1 out of frame.
|
||||
- Scene 2 → Scene N-1 (~1.0s each): Camera PANS (ease-in-out) to center the next station; on arrival its `[label k]` (+ optional `[secondary label]`) is REVEALED with the role reveal. Repeat per station.
|
||||
- Scene N (final, ~last beat): One last pan lands on the terminal station; the final `[callout / landing element]` reveals and HOLDS to the end. Camera goes static on the punchline.
|
||||
|
||||
- Variant — Hook: stations sit as evenly-spaced `[markers]` on a thin horizontal `[timeline]` (lower third); pans are LEFT-only along the single axis (timeline scrolls left). Each callout is a bordered `[callout box]` + downward triangle (offset drop-shadow) that SPRING-POPS up (scale 0→100%, bouncy overshoot, transform-origin at triangle tip) reading `[label k]`; a `[secondary label, e.g. year]` fades in and RISES above it. Some mid markers arrive as plain static text revealed by the pan alone (no box). Final scene lands on the `[present-day label]`, springs, holds.
|
||||
- Variant — Problem: stations are scattered across a 2D web; pans are DIAGONAL, STEERED by `[accent color]` hand-drawn lines — each station has a rough write-on line/arrow that draws toward the next and the camera follows it (Scene 1 also draws a loop/circle around the headline's key word). Each station = a white `[line-icon]` above its `[label]`, revealed plainly by the pan (no spring box). Final scene: the accent line spirals into a dense chaotic SCRIBBLE KNOT centered on the field; camera holds static on the tangle (visual punchline).
|
||||
|
||||
**motion vocabulary**
|
||||
repeated ease-in-out camera pans (horizontal-left for Hook, diagonal-steered for Problem) across one large static canvas; pre-placed stations sliding through frame via the pan; spring-overshoot callout pop with triangle-tip origin (Hook); rise-and-fade secondary label (Hook); plain labels/icons arriving via the pan alone; rough hand-drawn "write-on" leading lines/arrows + loop/circle key-word mark (Problem); terminal chaotic-scribble knot draw (Problem); static hold on the final station/punchline.
|
||||
|
||||
**rule mapping**
|
||||
|
||||
- camera pan / traverse across the canvas (primary) → `viewport-change` (single `.world` wrapper transform; PAN mode)
|
||||
- sequencing the repeated pan beats into stops → `multi-phase-camera`
|
||||
- centering each station as the pan target → `coordinate-target-zoom` (used as pan-to-target, no zoom)
|
||||
- spring-overshoot callout pop, triangle-tip origin (Hook) → `spring-pop-entrance`
|
||||
- rise-and-fade secondary label + plain per-station label/icon reveals via the pan → `discrete-text-sequence`
|
||||
- hand-drawn leading lines / arrows / loop-circle key-word mark / terminal scribble knot (Problem) → `svg-path-draw`
|
||||
- station line-icons (Problem) → `svg-icon-enrichment`
|
||||
- static hold on the final station / punchline → (no motion; sustained held frame, no rule needed)
|
||||
|
||||
**camera modifier**: The pan IS the camera. One `.world` virtual-camera transform in PAN mode — `viewport-change` — sequenced across stops by `multi-phase-camera`, each stop targeted via `coordinate-target-zoom` (pan-to-target). No depth push-in (that distinguishes this from the cluster-push-in / dataviz-pushthrough blueprints).
|
||||
@@ -0,0 +1,29 @@
|
||||
# ticker-takeover — Ticker Displace / Takeover
|
||||
|
||||
**intent**: A context phrase types in, an accent word cycles through options like a slot-machine to suggest "this could be many things," then a hero CRASHES in from off-screen and physically shoves the text aside — "actually, this is what it is." A collision, not a fade.
|
||||
|
||||
**roles served**
|
||||
|
||||
- Hook (from `takeover-ticker-displace`): when a static lead-in phrase + a cycling accent word should be **physically replaced** (not cross-dissolved) by a hero arriving with momentum, and the final frame is the hero alone. Reach for it when the takeover should read as an impact.
|
||||
- Brand_Outro: the same collision used as a sign-off — options cycle, the brand mark crashes in and owns the frame.
|
||||
|
||||
**duration**: 5–7s
|
||||
|
||||
**shot structure** (a `[bg]` canvas; one text group on the left/center that gets ejected by an incoming hero)
|
||||
|
||||
- **Scene 1 (0.0–~1.4s) — context build.** A typewriter lays down a `[lead-in phrase]` character-by-character (smooth, no typos — selling confidence, not human chaos). Camera static.
|
||||
- **Scene 2 (~1.4–3.0s) — the cycling beat.** An `[accent word]` slot inside the line ticks through 2–3 `[options]` on a vertical spring-roll (each click a new word), suggesting breadth — "many things this could be." (More than ~3 reads as filler.)
|
||||
- **Scene 3 (~3.0–4.2s) — the collision (signature move).** A `[hero]` crashes in from off-screen with momentum and physically SHOVES the whole text group aside — the text reacts to the impact (gets displaced), it does not fade. The hero lands **heavy** — a longer settle, not a zip — so it reads as mass, not speed.
|
||||
- **Scene 4 (~4.2–end) — the hero alone.** The hero settles dead-center and reads still. Holds.
|
||||
|
||||
**motion vocabulary**: smooth character typewriter; vertical spring-ticker word roll (2–3 steps); off-screen hero crash-in with momentum; reactive displacement of the struck text group; heavy long-tail landing (not bouncy); dual-axis subtle jitter on the resting hero.
|
||||
|
||||
**rule mapping**
|
||||
|
||||
- smooth single-phrase typewriter lead-in → `discrete-text-sequence` (smooth-slice / continuous `floor(progress)` form — no typo machinery)
|
||||
- accent word slot-machine cycling through options → `vertical-spring-ticker` (`STEPS` = number of options the hero will replace; the rule's footer-reveal is unused — Scene 3 takes its place)
|
||||
- hero shoves the text group aside on impact → `reactive-displacement` (the text is the displaced mass; express the hero's "heavy land" as a longer `power2` settle, not the rule's default `back.out`)
|
||||
- hero's fast off-screen crash-in → `motion-blur-streak` (directional velocity blur resolving sharp as it lands)
|
||||
- resting-hero aliveness → `sine-wave-loop` (low-amplitude dual-frequency register — scale + rotation jitter composing onto the hero's final landed scale; never a yoyo around 1)
|
||||
|
||||
**camera modifier**: camera-static — the displacement happens in element space (the hero moves the text), so there is no real camera move; the impact is the only motion.
|
||||
@@ -0,0 +1,42 @@
|
||||
# titlecard-reveal — Title-Card / Single-Card Reveal
|
||||
|
||||
**intent**: The calm breather/landing beat — one clean title or single brand/proof card revealed with exactly one restrained move (a slide-up crossfade, or a wipe-away-to-reveal), then a still hold. Low motion is the payload, not a deficiency.
|
||||
|
||||
**roles served**
|
||||
|
||||
- Benefits (from `benefits-titlecard-crossfade`, #34): a calm two-line value title card — headline value line, then one slide-up crossfade to a qualifier/elaboration line that holds center.
|
||||
- Social_Proof (from `social-proof-reveal-card`, #35): wipe a busy app-collage open away with one diagonal pill-sweep to reveal a clean brand lockup (icon + wordmark) plus a centered "loved by [N]+ [audience] teams" social-proof line that spring-settles and holds.
|
||||
|
||||
**duration**: 3–5s (Benefits 3–4s; Social_Proof ~5s / observed 4.7s).
|
||||
|
||||
**shot structure**
|
||||
|
||||
```
|
||||
Scene 1 (0.0–~0.4s): static camera on [neutral / dark background]. Establish the opening state.
|
||||
Variant — Benefits: empty-to-text — [benefit line 1] is about to fade in centered (no busy open).
|
||||
Variant — Social_Proof: a busy intro frame holds briefly — an [app-screenshot / use-case collage] of overlapping cards under a [setup line].
|
||||
|
||||
Scene 2 (~0.4–~1.5s): the ONE move executes — a single restrained reveal that brings the calm card to center.
|
||||
Variant — Benefits: [benefit line 1] fades in centered while scaling slightly (~95%→100%, smooth ease-out) and holds.
|
||||
Variant — Social_Proof: a large [accent-color] rounded pill sweeps diagonally bottom-left → top-right and exits the corner, clip-path wiping the collage away to reveal the [brand logo lockup] beneath as the [logo icon] strokes draw on.
|
||||
|
||||
Scene 3 (~1.5s–end): the revealed/settled card holds to the end (the allocated stillness). At most one subtle live element (a slow breathing pulse on the card, or a very slow camera drift). No second development phase.
|
||||
Variant — Benefits: [benefit line 1] translates up and fades out as [benefit line 2 — qualifier / elaboration] translates up from below center and fades in to take center; holds. (This single slide-up crossfade IS the one move — Benefits front-loads no Scene-2 wipe.)
|
||||
Variant — Social_Proof: the lockup — [logo icon] centered, [wordmark] below, centered [social-proof tagline] "Loved by [N]+ [audience] teams" (the [N]+ may count up) — spring-settles small, then holds.
|
||||
```
|
||||
|
||||
**motion vocabulary**: single restrained reveal (gentle fade-in + subtle scale-up settle | diagonal clip-path pill-wipe), one slide-up crossfade between two centered lines (Benefits), icon stroke draw-on (Social_Proof), optional "[N]+ teams" count-up, logo+tagline spring-settle-and-hold, subtle breathing on the held card, hold-to-end. Calm register — no spring chains, no tumble, no per-beat flips, no second phase. Camera static (optional very slow drift only).
|
||||
|
||||
**rule mapping**
|
||||
|
||||
- gentle fade-in + subtle scale-up settle (Benefits Scene 2) → `rules/scale-swap-transition.md` (restrained in/settle; cross-reference the fade ease in `techniques.md`)
|
||||
- single slide-up crossfade between two centered lines (Benefits Scene 3) → `rules/discrete-text-sequence.md` (one line hands off to the next; translate-up + crossfade)
|
||||
- diagonal pill-wipe reveal (Social_Proof Scene 2) → `rules/techniques.md` (clip-path reveal masks — the wipe)
|
||||
- icon stroke draw-on (Social_Proof Scene 2) → `rules/svg-path-draw.md`
|
||||
- "[N]+ teams" count-up (Social_Proof Scene 3, optional) → `rules/counting-dynamic-scale.md`
|
||||
- logo + tagline spring-settle-and-hold (Social_Proof Scene 3) → `rules/spring-pop-entrance.md` (single soft settle; intentionally one beat, not a chain)
|
||||
- subtle breathing on the held card (the one live element during the hold) → `rules/sine-wave-loop.md`
|
||||
|
||||
**camera modifier**: optional — a single very slow drift/push under the hold only → `rules/multi-phase-camera.md`. Default is fully static; do not add unless the held beat would otherwise read as a freeze-frame.
|
||||
|
||||
**stillness note**: This is a legitimate allocated-stillness beat. The hold in Scene 3 is the deliverable, not an unanimated gap — do NOT manufacture a development phase, extra swaps, or force-animation. One restrained move + a subtle hold (optionally one breathing element or one slow drift) is the correct and complete shape.
|
||||
@@ -0,0 +1,51 @@
|
||||
# typewriter-reveal — Typewriter Reveal
|
||||
|
||||
**intent**: A live text caret types (and edits) a line as a human would, then either collapses it to a point and pops a brand payoff, or holds it under a persistent brand mark while a sub-line types/swaps into the final CTA — making "someone is typing this" the engine of the shot.
|
||||
|
||||
**roles served**
|
||||
|
||||
- Hook (from hook-typed-line-to-reveal): Type a relatable question/statement live, then COLLAPSE it and spring-pop the brand — a logo lockup OR a product-UI moment ("here's the everyday pain, now here's us").
|
||||
- Brand_Outro (from brand-outro-persistent-mark-cta-rail): Hold the hero mark dead-center/top the whole shot while a sub-line beneath it swaps or types its way into the final CTA — landing the ask once the logo is already established.
|
||||
|
||||
**duration**: 3.6–7s (Brand_Outro 3.6–6.0s · Hook 5.5–7s)
|
||||
|
||||
**shot structure** (one consolidated template; `[slots]` are product-agnostic)
|
||||
|
||||
- Scene 1 (0.0–~2.0s): On a solid `[bg color]` field, a blinking text-input caret `|` sits at the line start, then `[primary line]` TYPES on character-by-character with the caret trailing.
|
||||
- _Variant — Hook_: nothing else is on screen; the typed `[hook line]` owns the frame. (Sub-variant: the line types inside UI chrome — a rounded `[input/pill]` — and the whole assembly continuously TRANSLATES leftward + scales slightly so the active caret stays pinned near frame-center while earlier words scroll off and clip past the left edge — a ticker push.)
|
||||
- _Variant — Brand_Outro_: a `[logo mark]` (+ optional `[wordmark]`) is already centered/upper and STAYS fully visible for the entire shot; an entry flourish plays on the mark itself (e.g. `[checkmark/icon]` strokes into the mark, or thin concentric rings ripple outward from it), and the typed `[tagline / product label]` is the SUB-LINE beneath the mark.
|
||||
|
||||
- Scene 2 (~2.0–4.5s): The typed line is MODIFIED in place — the active text is edited rather than re-shot.
|
||||
- _Variant — Hook_: final word(s) BACKSPACE out and a new word RETYPES (`[word A]` → `[word B]`), or the fill/caret snaps to `[accent color]` on the final word. Holds briefly.
|
||||
- _Variant — Brand_Outro_: the sub-line is REMOVED in place — a direct hard CUT/replace (NO backspace) or a moving mask-WIPE erases it — while the mark performs a small idle move (gentle rotate / sparkle reposition); the mark never leaves frame.
|
||||
|
||||
- Scene 3 — resolve:
|
||||
- _Variant — Hook (collapse, ~0.3–0.7s)_: caret vanishes; the whole text/assembly COLLAPSES to a point at center (horizontal X-collapse or scale-to-0 zoom-out) and disappears, leaving a clean `[bg]`. Then (remainder) a centered `[brand element]` SPRING-POPS in:
|
||||
- _logo-lockup sub-variant_: a `[mark/icon]` pops, then slides aside as a `[wordmark]` UNMASKS / slides out from behind it; both settle into a centered lockup.
|
||||
- _product-UI sub-variant_: a `[UI control]` (e.g. button) pops; a `[cursor]` sweeps in from a corner and homes onto it; on contact a ~150ms state-FLIP — base cross-fades to `[accent color]`, icon inverts, and a soft radial GLOW blooms outward and persists.
|
||||
- _Variant — Brand_Outro (~4.5s–end)_: the final `[CTA]` resolves in the sub-line slot — TYPED in with a caret and/or shown as a `[CTA in accent-color button]` beside plain text; an optional `[accent color]` GLOW ring / halo settles around the persistent mark. Holds to end. Final frame: `[logo mark]` + (glow ring) + `[CTA]`.
|
||||
|
||||
**motion vocabulary**: blinking text caret; character-by-character type-on; backspace-and-retype OR in-place hard-cut/mask-wipe text swap; optional leftward ticker push (assembly translates to keep caret centered); persistent centered hero mark (never vanishes) with entry flourish (icon stroke-draw, concentric ripple rings) and small idle move (rotate / sparkle); X-collapse / scale-to-0 zoom-out of the typed line; spring-pop brand reveal; wordmark unmask-slide into lockup; cursor sweep + UI state-flip + radial glow bloom; accent glow/halo ring settle; pill/button CTA reveal; hold.
|
||||
|
||||
**rule mapping** (per motion verb → `rules/<id>.md`)
|
||||
|
||||
- blinking text caret → `context-sensitive-cursor` (caret color-switch + blink)
|
||||
- character-by-character type-on → `discrete-text-sequence` (typing/typos/holds/backspace); recipe `gsap-effects` (typewriter)
|
||||
- backspace-and-retype → `discrete-text-sequence`
|
||||
- in-place hard-cut / replace text swap → `discrete-text-sequence` (whole-text state swaps)
|
||||
- mask-wipe erase of sub-line → `techniques.md` clip-path reveal (run in reverse)
|
||||
- leftward ticker push (assembly translates to keep caret centered) → `camera-cursor-tracking` (viewport follows a moving caret)
|
||||
- persistent hero mark hold → no motion rule needed (static anchor; intentional — it's the absence of motion)
|
||||
- entry flourish: icon stroke-draw into mark → `svg-path-draw`
|
||||
- entry flourish: concentric ripple rings from mark → `cursor-click-ripple` (ripple bloom)
|
||||
- small idle mark move (rotate / sparkle reposition) → `sine-wave-loop` (idle)
|
||||
- X-collapse / scale-to-0 zoom-out of typed line → `scale-swap-transition` (closest fit — it morphs/collapses elements at a shared center; approximation, since a standalone collapse-and-vanish without the paired same-center brand pop isn't its exact case)
|
||||
- spring-pop brand reveal → `spring-pop-entrance` (alt `physics-press-reaction`)
|
||||
- collapse-text → pop-brand as a same-center morph pair → `scale-swap-transition` (morph two elements at same center)
|
||||
- wordmark unmask-slide into lockup → `techniques.md` clip-path reveal (unmask); slide via `spring-pop-entrance`
|
||||
- cursor sweep onto UI control + press → `cursor-click-ripple` (cursor→target press + ripple)
|
||||
- UI state-flip (base/icon invert on contact) → `hacker-flip-3d`
|
||||
- radial glow bloom / accent glow-halo ring settle → `asr-keyword-glow` (accent glow); ring expansion via `center-outward-expansion`
|
||||
- pill/button CTA reveal → `spring-pop-entrance` (alt `scale-swap-transition`)
|
||||
|
||||
**camera modifier**: none required — camera is static for both roles. The Hook ticker push is an ELEMENT translate (the typed assembly slides leftward to keep the caret centered), not a camera move → modeled by `camera-cursor-tracking` rather than a true camera rule.
|
||||
@@ -0,0 +1,30 @@
|
||||
# video-text-pivot — Video → Text Pivot
|
||||
|
||||
**intent**: A product video holds center and claims attention, then slides aside to hand its weight to a hero stat in the space it vacates, then both clear and kinetic text types into the center — accent words carrying the meaning the video used to carry — sealed by a gradient pill. The arc is "show → yield → pivot → stamp," and each handoff pairs an exit with a same-anchor entrance so two beats read, not four.
|
||||
|
||||
**roles served**
|
||||
|
||||
- Product_Intro (from `metric-video-text-pivot`): when the open is "see the feature" then "see the impact" and the `[product video]` must stay visible through the stat reveal — it slides, it doesn't cut.
|
||||
- Key_Feature: a feature clip that yields to a frame-filling metric and a typographic impact line.
|
||||
|
||||
**duration**: 6–8s
|
||||
|
||||
**shot structure** (a `[bg]` canvas; one `[product video]` as a real muted `.mp4` clip, a hero stat, then kinetic text — each pair shares a screen anchor so the handoff reads as a weight-transfer)
|
||||
|
||||
- **Scene 1 (0.0–~1.6s) — the video shows.** The `[product video]` lands centered on a smooth scale-up and breathes (a small y-bob), claiming full attention. Camera static.
|
||||
- **Scene 2 (~1.6–3.2s) — yield + stat (signature move).** The video SLIDES aside (x + scale down) **into the very space** the `[hero stat]` now fills as the stat pops in with 3D-depth type — one weight-transfer reading as a single event, not two. The stat breathes within this window.
|
||||
- **Scene 3 (~3.2–5.0s) — pivot to text.** Both video and stat clear out and kinetic `[impact text]` TYPES into the vacated center, character by character; its `[accent words]` carry the meaning the video used to carry.
|
||||
- **Scene 4 (~5.0–end) — stamp.** A gradient `[pill]` snaps shut around the closing line (`scaleX` 0→1), its glow halo resolving a beat behind so the silhouette reads before the bloom — sealing the statement as one graphic. Holds.
|
||||
|
||||
**motion vocabulary**: video scale-in + small breath; weight-transfer slide (video x + scale-down handing off to the stat at the same anchor); 3D-depth stat type; character-stream typing; gradient pill scaleX-snap; glow-halo bloom trailing the silhouette.
|
||||
|
||||
**rule mapping**
|
||||
|
||||
- video entrance (smooth) and the weight-transfer slide → `gsap-effects` (scale/opacity then x + scale on a long-tail `power3`); the video itself is a muted `<video class="clip">` direct child of the root
|
||||
- hero stat's frame-filling 3D type → `3d-text-depth-layers` (static-depth variation — layers built at setup, no cascade fighting the entry)
|
||||
- the same-anchor video-exit ↔ stat-entry handoff (if treated as a morph) → `scale-swap-transition` (shared center)
|
||||
- character-by-character impact typing through segmented spans → `dynamic-content-sequencing` (clean character stream) or `discrete-text-sequence`
|
||||
- pill `scaleX` snap + trailing glow halo → `gsap-effects` (scaleX) + `ambient-glow-bloom` (the halo, resolving a beat behind)
|
||||
- video / stat breath within their windows → `sine-wave-loop` (low-amplitude register — subtle jitter, gated to each element's window, never a forever loop)
|
||||
|
||||
**camera modifier**: camera-static — all motion is element-space (the video translates), so the "pivot" is the elements moving, not a camera.
|
||||
@@ -0,0 +1,382 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=1920, height=1080" />
|
||||
<title>Scene 3 — Assembly Focus Reveal (hyperframes)</title>
|
||||
|
||||
<!--
|
||||
HyperFrames composition.tsx.
|
||||
|
||||
Choreography (5 phases, 5 seconds total):
|
||||
0.00 – 0.67s "Just use" assembles in a discrete sequence (with hold)
|
||||
0.73 – 1.23s Pink logo pops in with back.out elastic
|
||||
1.50 – 2.20s "Just use" slides left + fades; container recenters around brand
|
||||
2.67 – 3.57s Camera zooms 5.5× into the logo (scale + counter-translate)
|
||||
3.67 – 5.00s Logo breathes (sine onUpdate, multiplicative on pop scale)
|
||||
|
||||
Key differences :
|
||||
- Single paused GSAP timeline registered to window.__timelines["main"]
|
||||
- brandTextWidth measured via getBoundingClientRect after document.fonts.ready
|
||||
- HERO_FINAL_OFFSET_X derived from real measurement, not estimate
|
||||
- Breathing uses the onUpdate (multiplicative) form so it multiplies
|
||||
onto the pop scale, not a fromTo + yoyo which would overwrite the pop value
|
||||
- Three nested transform layers: zoom-scale → zoom-translate → recenter-shift
|
||||
- Logo is loaded from a static PNG asset
|
||||
-->
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg: #000000;
|
||||
--text-white: #ffffff;
|
||||
--pink: #e91e63;
|
||||
}
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
font-family: "Google Sans", "Roboto", Inter, system-ui, sans-serif;
|
||||
color: var(--text-white);
|
||||
}
|
||||
|
||||
.stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Three nested transform layers (outer → inner) */
|
||||
.zoom-scale {
|
||||
transform-origin: center center;
|
||||
will-change: transform;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.zoom-translate {
|
||||
will-change: transform;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.recenter-shift {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Companion text — fixed width to prevent assembly jitter */
|
||||
.companion {
|
||||
width: 600px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-right: 30px;
|
||||
white-space: nowrap;
|
||||
color: var(--text-white);
|
||||
font-size: 140px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Brand group: text + hero icon */
|
||||
.brand-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
.brand-text {
|
||||
color: var(--text-white);
|
||||
font-size: 140px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
}
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
/* initial scale(0) set by GSAP fromTo */
|
||||
}
|
||||
.hero .logo-mark {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Hidden probe used for text measurement */
|
||||
.measure-probe {
|
||||
position: absolute;
|
||||
left: -99999px;
|
||||
top: -99999px;
|
||||
visibility: hidden;
|
||||
white-space: pre;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="root"
|
||||
data-composition-id="main"
|
||||
data-start="0"
|
||||
data-duration="5"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
>
|
||||
<div
|
||||
class="stage clip"
|
||||
data-start="0"
|
||||
data-duration="5"
|
||||
data-track-index="1"
|
||||
id="brand-stage"
|
||||
>
|
||||
<div class="zoom-scale" data-layout-allow-overflow>
|
||||
<div class="zoom-translate">
|
||||
<div class="recenter-shift">
|
||||
<div class="companion">
|
||||
<span class="companion-text">J</span>
|
||||
</div>
|
||||
|
||||
<div class="brand-group" data-layout-allow-overflow>
|
||||
<span class="brand-text">Hyperframes</span>
|
||||
<div class="hero">
|
||||
<!-- Inline-SVG placeholder mark — swap for your logo image -->
|
||||
<svg
|
||||
class="logo-mark"
|
||||
viewBox="0 0 100 100"
|
||||
role="img"
|
||||
aria-label="hyperframes logo"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="hfMark" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#8b7bff" />
|
||||
<stop offset="1" stop-color="#3ddc97" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="4" y="4" width="92" height="92" rx="22" fill="url(#hfMark)" />
|
||||
<text
|
||||
x="50"
|
||||
y="63"
|
||||
text-anchor="middle"
|
||||
font-family="Inter, system-ui, sans-serif"
|
||||
font-size="40"
|
||||
font-weight="800"
|
||||
fill="#fff"
|
||||
>
|
||||
HF
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ================================================================
|
||||
CONSTANTS — composition dimensions baked in.
|
||||
================================================================ */
|
||||
const W = 1920,
|
||||
H = 1080;
|
||||
const TOTAL_DURATION = 5.0;
|
||||
|
||||
const COMPANION_WIDTH = 600;
|
||||
const COMPANION_GAP = 30; // margin-right on .companion
|
||||
const BRAND_FONT_SIZE = 140;
|
||||
const HERO_GAP = 20;
|
||||
const HERO_SIZE = 140;
|
||||
|
||||
const TIMING = {
|
||||
// Phase 1: companion assembly
|
||||
textStart: 0.0,
|
||||
textEnd: 0.67,
|
||||
|
||||
// Phase 2: hero pop
|
||||
popStart: 0.73,
|
||||
popDur: 0.5,
|
||||
|
||||
// Phase 3: slide-out + recenter
|
||||
slideStart: 1.5,
|
||||
slideDur: 0.7,
|
||||
|
||||
// Phase 4: zoom
|
||||
zoomStart: 2.67,
|
||||
zoomDur: 0.9,
|
||||
|
||||
// Phase 5: breathing
|
||||
breathStart: 3.67,
|
||||
};
|
||||
|
||||
const FINAL_RECENTER_OFFSET = -180; // pre-calculated, tuned for visual feel
|
||||
|
||||
/* ================================================================
|
||||
DISCRETE TEXT SEQUENCE for the companion assembly.
|
||||
Converted from the source frame-based sequence to seconds at 30fps.
|
||||
================================================================ */
|
||||
const SEQUENCE = [
|
||||
{ t: 0.0, text: "J" },
|
||||
{ t: 0.07, text: "Jus" },
|
||||
{ t: 0.13, text: "Just" },
|
||||
{ t: 0.27, text: "Just" }, // hold for pacing
|
||||
{ t: 0.4, text: "Just u" },
|
||||
{ t: 0.53, text: "Just us" },
|
||||
{ t: 0.67, text: "Just use" },
|
||||
];
|
||||
|
||||
/* ================================================================
|
||||
TIMELINE — built synchronously so HyperFrames can seek it.
|
||||
================================================================ */
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
window.__timelines["main"] = tl;
|
||||
|
||||
{
|
||||
/* Measure the brand text width with a hidden DOM probe. */
|
||||
const probe = document.createElement("span");
|
||||
probe.className = "measure-probe";
|
||||
probe.style.font = `700 ${BRAND_FONT_SIZE}px "Google Sans", "Roboto", Inter, system-ui, sans-serif`;
|
||||
probe.style.whiteSpace = "pre";
|
||||
probe.style.lineHeight = "1";
|
||||
probe.textContent = "Hyperframes"; // MUST match the rendered .brand-text casing
|
||||
document.body.appendChild(probe);
|
||||
const brandTextWidth = probe.getBoundingClientRect().width;
|
||||
probe.remove();
|
||||
|
||||
/* Derive the hero's post-Phase-3 offset from viewport center.
|
||||
baseHeroOffset = (C + G + B + L) / 2 <- heroSize cancels out
|
||||
HERO_FINAL_OFFSET_X = baseHeroOffset + FINAL_RECENTER_OFFSET */
|
||||
const baseHeroOffset = (COMPANION_WIDTH + COMPANION_GAP + brandTextWidth + HERO_GAP) / 2;
|
||||
const HERO_FINAL_OFFSET_X = baseHeroOffset + FINAL_RECENTER_OFFSET;
|
||||
|
||||
/* ============================================================
|
||||
PHASE 1: Discrete companion assembly
|
||||
============================================================ */
|
||||
const textEl = document.querySelector(".companion-text");
|
||||
|
||||
for (const entry of SEQUENCE) {
|
||||
tl.set(textEl, { textContent: entry.text }, entry.t);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
PHASE 2: Hero pops in (elastic)
|
||||
============================================================ */
|
||||
tl.fromTo(
|
||||
".hero",
|
||||
{ scale: 0 },
|
||||
{
|
||||
scale: 1,
|
||||
duration: TIMING.popDur,
|
||||
ease: "back.out(2)", // spring(stiffness:200, damping:12)
|
||||
},
|
||||
TIMING.popStart,
|
||||
);
|
||||
|
||||
/* ============================================================
|
||||
PHASE 3: Companion exit + container recenter
|
||||
Two concurrent tweens at the same timeline position.
|
||||
============================================================ */
|
||||
tl.to(
|
||||
".companion",
|
||||
{
|
||||
opacity: 0,
|
||||
x: -80,
|
||||
duration: TIMING.slideDur,
|
||||
ease: "power3.out", // spring(stiffness:100, damping:20)
|
||||
},
|
||||
TIMING.slideStart,
|
||||
);
|
||||
|
||||
tl.to(
|
||||
".recenter-shift",
|
||||
{
|
||||
x: FINAL_RECENTER_OFFSET,
|
||||
duration: TIMING.slideDur,
|
||||
ease: "power3.out",
|
||||
},
|
||||
TIMING.slideStart,
|
||||
);
|
||||
|
||||
/* ============================================================
|
||||
PHASE 4: Zoom — scale (outer) + counter-translate (middle) + brand text exits.
|
||||
============================================================ */
|
||||
tl.to(
|
||||
".zoom-scale",
|
||||
{
|
||||
scale: 5.5,
|
||||
duration: TIMING.zoomDur,
|
||||
ease: "power2.out", // spring(stiffness:80, damping:20, mass:1.5)
|
||||
},
|
||||
TIMING.zoomStart,
|
||||
);
|
||||
|
||||
tl.to(
|
||||
".zoom-translate",
|
||||
{
|
||||
x: -HERO_FINAL_OFFSET_X,
|
||||
y: 0,
|
||||
duration: TIMING.zoomDur,
|
||||
ease: "power2.out",
|
||||
},
|
||||
TIMING.zoomStart,
|
||||
);
|
||||
|
||||
// Brand text fades out + slides left so the logo gets all the zoom space.
|
||||
tl.to(
|
||||
".brand-text",
|
||||
{
|
||||
opacity: 0,
|
||||
x: -600,
|
||||
duration: TIMING.zoomDur * 0.4,
|
||||
ease: "power2.out",
|
||||
},
|
||||
TIMING.zoomStart,
|
||||
);
|
||||
|
||||
/* ============================================================
|
||||
PHASE 5: Breathing — onUpdate so it MULTIPLIES on the hero's
|
||||
final pop scale, doesn't overwrite it.
|
||||
============================================================ */
|
||||
const heroEl = document.querySelector(".hero");
|
||||
const HERO_FINAL_SCALE = 1.0;
|
||||
const SCALE_PERIOD = 1.5; // seconds per cycle
|
||||
const SCALE_AMP = 0.04;
|
||||
const ROTATE_AMP = 2;
|
||||
|
||||
const breathDur = TOTAL_DURATION - TIMING.breathStart;
|
||||
|
||||
tl.to(
|
||||
{ tick: 0 },
|
||||
{
|
||||
tick: 1,
|
||||
duration: breathDur,
|
||||
ease: "none",
|
||||
onUpdate: function () {
|
||||
const idleTime = Math.max(0, tl.time() - TIMING.breathStart);
|
||||
const omega = (idleTime / SCALE_PERIOD) * Math.PI * 2;
|
||||
gsap.set(heroEl, {
|
||||
scale: HERO_FINAL_SCALE * (1 + Math.sin(omega) * SCALE_AMP),
|
||||
rotation: Math.sin(omega) * ROTATE_AMP,
|
||||
});
|
||||
},
|
||||
},
|
||||
TIMING.breathStart,
|
||||
);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,649 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=1920, height=1080" />
|
||||
<title>Scene 09 — Split Comparison Reveal</title>
|
||||
|
||||
<!--
|
||||
HyperFrames composition.tsx.
|
||||
|
||||
Choreography (3 phases, 5 seconds total):
|
||||
0.17 – 0.83s Title slides down from top: "Build Video With HyperFrames"
|
||||
0.50 – 1.83s Left card "HTML Composition" enters from left (+16° rotateY)
|
||||
0.83 – 1.83s Right card "Render Pipeline" enters from right (-16° rotateY)
|
||||
1.67 – 2.17s Left pill badge "Seekable Timeline" pops in with back.out(1.7)
|
||||
2.00 – 2.50s Right pill badge "Render Ready" pops in
|
||||
0 – 5.00s Continuous floating: cards y ±6 px / rotation ±1° (phase-opposed)
|
||||
Badges y ±5 px (slow shared sine)
|
||||
and .card-tilt (static rotateY + float rotation) so entry and float
|
||||
don't fight on the same alias
|
||||
- All continuous floating consolidated into one shared scene-ticker onUpdate
|
||||
(6 gsap.set calls per frame, batched by the browser)
|
||||
- Phase offset Math.PI between the two cards' floats — opposed breathing
|
||||
- Card images are HyperFrames workflow mockups (no asset files needed)
|
||||
- Ambient dual-glow via two radial-gradients in a single overlay
|
||||
-->
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg-dark: #0a1415;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: rgba(255, 255, 255, 0.65);
|
||||
--brand-cyan: #18d9e8;
|
||||
--brand-cyan-glow: rgba(24, 217, 232, 1);
|
||||
--brand-green: #7bea5a;
|
||||
--brand-green-glow: rgba(123, 234, 90, 1);
|
||||
--glass-bg: rgba(255, 255, 255, 0.08);
|
||||
--border: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-dark);
|
||||
font-family:
|
||||
"Inter",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
BACKGROUND
|
||||
============================================================ */
|
||||
.bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse at 35% 50%, rgba(24, 217, 232, 0.2), transparent 45%),
|
||||
radial-gradient(ellipse at 72% 50%, rgba(123, 234, 90, 0.16), transparent 45%),
|
||||
linear-gradient(135deg, #3a3a3a 0%, #17211f 30%, #0b2328 48%, #1f3518 70%, #343434 100%);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
TITLE
|
||||
============================================================ */
|
||||
.title {
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 88px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
text-align: center;
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
will-change: transform, opacity;
|
||||
/* initial opacity 0 + y -40 set via gsap.set */
|
||||
}
|
||||
.title .accent {
|
||||
color: var(--brand-cyan);
|
||||
text-shadow: 0 0 34px rgba(24, 217, 232, 0.28);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
CARDS ROW
|
||||
============================================================ */
|
||||
.cards-row {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
gap: 76px;
|
||||
padding-top: 30px;
|
||||
perspective: 980px;
|
||||
perspective-origin: 50% 42%;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
/* Two nested wrappers per card */
|
||||
.card {
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
.card-pos {
|
||||
perspective: 980px;
|
||||
perspective-origin: 50% 45%;
|
||||
transform-style: preserve-3d;
|
||||
will-change: transform, opacity;
|
||||
/* initial x/scale/opacity set via gsap.set */
|
||||
}
|
||||
.card-tilt {
|
||||
transform-style: preserve-3d;
|
||||
will-change: transform;
|
||||
/* initial rotationY set via gsap.set */
|
||||
}
|
||||
.card-content {
|
||||
width: 720px;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
.card-image {
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
position: relative;
|
||||
transform: translateZ(0) rotateX(0.01deg);
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
.card-left .card-tilt {
|
||||
transform-origin: 100% 50%;
|
||||
}
|
||||
.card-right .card-tilt {
|
||||
transform-origin: 0% 50%;
|
||||
}
|
||||
.card-label {
|
||||
margin-top: 26px;
|
||||
text-align: center;
|
||||
font-size: 54px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
transform: translateZ(34px);
|
||||
text-shadow: 0 0 30px rgba(24, 217, 232, 0.16);
|
||||
}
|
||||
.card-subtitle {
|
||||
margin-top: 8px;
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
color: var(--text-secondary);
|
||||
transform: translateZ(26px);
|
||||
}
|
||||
|
||||
/* Left card: shadow falls right (positive x in box-shadow = right) */
|
||||
.card-left .card-image {
|
||||
box-shadow:
|
||||
30px 30px 60px rgba(0, 0, 0, 0.45),
|
||||
0 0 60px rgba(24, 217, 232, 0.2);
|
||||
}
|
||||
/* Right card: shadow falls left */
|
||||
.card-right .card-image {
|
||||
box-shadow:
|
||||
-30px 30px 60px rgba(0, 0, 0, 0.45),
|
||||
0 0 60px rgba(123, 234, 90, 0.2);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
CARD IMAGE PLACEHOLDERS (mock UI)
|
||||
============================================================ */
|
||||
/* Left card — HTML Composition: gradient + timed clip grid */
|
||||
.card-left .card-image {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(12, 38, 42, 0.96) 0%,
|
||||
rgba(18, 66, 63, 0.9) 52%,
|
||||
rgba(32, 57, 38, 0.94) 100%
|
||||
);
|
||||
}
|
||||
.mock-templates {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 56px 66px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: repeat(2, 1fr);
|
||||
gap: 22px;
|
||||
opacity: 0.22;
|
||||
}
|
||||
.mock-thumb {
|
||||
background: linear-gradient(135deg, rgba(24, 217, 232, 0.2), rgba(123, 234, 90, 0.08));
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-start;
|
||||
padding: 14px;
|
||||
transform: translateZ(18px);
|
||||
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.mock-thumb .label {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
/* Right card — Render Pipeline: validation rows */
|
||||
.card-right .card-image {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(23, 45, 34, 0.96) 0%,
|
||||
rgba(17, 70, 73, 0.9) 50%,
|
||||
rgba(38, 57, 45, 0.94) 100%
|
||||
);
|
||||
}
|
||||
.mock-team {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 66px 72px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
opacity: 0.24;
|
||||
}
|
||||
.mock-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 15px 20px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
transform: translateZ(18px);
|
||||
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.mock-avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(135deg, var(--brand-green), var(--brand-cyan));
|
||||
}
|
||||
.mock-avatar.purple {
|
||||
background: linear-gradient(135deg, var(--brand-cyan), #38bdf8);
|
||||
}
|
||||
.mock-avatar.green {
|
||||
background: linear-gradient(135deg, var(--brand-green), var(--brand-cyan));
|
||||
}
|
||||
.mock-avatar.orange {
|
||||
background: linear-gradient(135deg, #facc15, var(--brand-green));
|
||||
}
|
||||
.mock-row .name {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
.mock-row .status {
|
||||
margin-left: auto;
|
||||
font-size: 16px;
|
||||
color: var(--brand-green);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
FLOATING BADGES
|
||||
============================================================ */
|
||||
.badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 22px;
|
||||
border-radius: 999px;
|
||||
background-color: var(--glass-bg);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
will-change: transform, opacity;
|
||||
z-index: 50;
|
||||
}
|
||||
.badge-left {
|
||||
border: 1px solid rgba(24, 217, 232, 0.4);
|
||||
box-shadow: 0 0 30px rgba(24, 217, 232, 0.35);
|
||||
}
|
||||
.badge-right {
|
||||
border: 1px solid rgba(123, 234, 90, 0.4);
|
||||
box-shadow: 0 0 30px rgba(123, 234, 90, 0.35);
|
||||
}
|
||||
.badge-icon-wrap {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--bg-dark);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.badge-left .badge-icon-wrap {
|
||||
background-color: var(--brand-cyan);
|
||||
}
|
||||
.badge-right .badge-icon-wrap {
|
||||
background-color: var(--brand-green);
|
||||
}
|
||||
.badge-label {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
AMBIENT GLOW + VIGNETTE
|
||||
============================================================ */
|
||||
.ambient-glow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse at 30% 50%, var(--brand-cyan-glow) 0%, transparent 35%),
|
||||
radial-gradient(ellipse at 70% 50%, var(--brand-green-glow) 0%, transparent 35%);
|
||||
opacity: 0.13;
|
||||
pointer-events: none;
|
||||
}
|
||||
.vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse at center, transparent 40%, rgba(0, 0, 0, 0.45) 100%);
|
||||
pointer-events: none;
|
||||
z-index: 400;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="root"
|
||||
data-composition-id="main"
|
||||
data-start="0"
|
||||
data-duration="5"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
>
|
||||
<div
|
||||
class="stage clip"
|
||||
data-start="0"
|
||||
data-duration="5"
|
||||
data-track-index="1"
|
||||
id="scene-stage"
|
||||
>
|
||||
<div class="bg"></div>
|
||||
|
||||
<!-- Title -->
|
||||
<div class="title" id="title">Build Video With <span class="accent">HyperFrames</span></div>
|
||||
|
||||
<!-- Cards row -->
|
||||
<div class="cards-row">
|
||||
<!-- Left card: HTML Composition -->
|
||||
<div class="card card-left" id="card-left">
|
||||
<div class="card-pos">
|
||||
<div class="card-tilt">
|
||||
<div class="card-content">
|
||||
<div class="card-image">
|
||||
<div class="mock-templates">
|
||||
<div class="mock-thumb"><span class="label">HTML</span></div>
|
||||
<div class="mock-thumb"><span class="label">CSS</span></div>
|
||||
<div class="mock-thumb"><span class="label">GSAP</span></div>
|
||||
<div class="mock-thumb"><span class="label">Audio</span></div>
|
||||
<div class="mock-thumb"><span class="label">Captions</span></div>
|
||||
<div class="mock-thumb"><span class="label">Assets</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-label">HTML Composition</div>
|
||||
<div class="card-subtitle">Timed DOM clips, media, and motion</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right card: Render Pipeline -->
|
||||
<div class="card card-right" id="card-right">
|
||||
<div class="card-pos">
|
||||
<div class="card-tilt">
|
||||
<div class="card-content">
|
||||
<div class="card-image">
|
||||
<div class="mock-team">
|
||||
<div class="mock-row">
|
||||
<div class="mock-avatar purple"></div>
|
||||
<span class="name">Register timeline</span>
|
||||
<span class="status">● seekable</span>
|
||||
</div>
|
||||
<div class="mock-row">
|
||||
<div class="mock-avatar green"></div>
|
||||
<span class="name">Validate layout</span>
|
||||
<span class="status">● clean</span>
|
||||
</div>
|
||||
<div class="mock-row">
|
||||
<div class="mock-avatar orange"></div>
|
||||
<span class="name">Render frames</span>
|
||||
<span class="status">● stable</span>
|
||||
</div>
|
||||
<div class="mock-row">
|
||||
<div class="mock-avatar"></div>
|
||||
<span class="name">Publish MP4</span>
|
||||
<span class="status">● ready</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-label">Render Pipeline</div>
|
||||
<div class="card-subtitle">Preview, check, render, publish</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating badges -->
|
||||
<div class="badge badge-left" id="badge-left">
|
||||
<div class="badge-icon-wrap">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 2L2 7L12 12L22 7L12 2Z" />
|
||||
<path d="M2 17L12 22L22 17" />
|
||||
<path d="M2 12L12 17L22 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="badge-label">Seekable Timeline</span>
|
||||
</div>
|
||||
|
||||
<div class="badge badge-right" id="badge-right">
|
||||
<div class="badge-icon-wrap">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M17 21V19C17 16.7909 15.2091 15 13 15H5C2.79086 15 1 16.7909 1 19V21" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21V19C22.9986 17.1771 21.765 15.5857 20 15.13" />
|
||||
<path
|
||||
d="M16 3.13C17.7699 3.58317 19.0078 5.17799 19.0078 7.005C19.0078 8.83201 17.7699 10.4268 16 10.88"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="badge-label">Render Ready</span>
|
||||
</div>
|
||||
|
||||
<div class="ambient-glow"></div>
|
||||
</div>
|
||||
|
||||
<div class="vignette"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ================================================================
|
||||
CONSTANTS
|
||||
================================================================ */
|
||||
const W = 1920,
|
||||
H = 1080;
|
||||
const TOTAL_DUR = 5.0;
|
||||
|
||||
const TIMING = {
|
||||
// Phase 1: title
|
||||
titleAt: 0.17,
|
||||
titleDur: 0.67,
|
||||
|
||||
// Phase 2: cards
|
||||
leftAt: 0.5,
|
||||
rightAt: 0.83,
|
||||
entryDur: 0.7,
|
||||
slideDist: 100,
|
||||
baseTilt: 18,
|
||||
|
||||
// Phase 3: badges
|
||||
badgeLeftAt: 1.67,
|
||||
badgeRightAt: 2.0,
|
||||
badgeEntryDur: 0.5,
|
||||
|
||||
// Continuous float
|
||||
floatYSpeed: 0.02 * 30, // = 0.6 rad/sec
|
||||
floatYAmp: 6,
|
||||
floatRSpeed: 0.015 * 30, // = 0.45 rad/sec
|
||||
floatRAmp: 1,
|
||||
badgeYSpeed: 0.025 * 30, // = 0.75 rad/sec
|
||||
badgeYAmp: 5,
|
||||
};
|
||||
|
||||
/* ================================================================
|
||||
BADGE POSITIONS — set via CSS left/top once (not tweened)
|
||||
================================================================ */
|
||||
const badgeLeftEl = document.getElementById("badge-left");
|
||||
const badgeRightEl = document.getElementById("badge-right");
|
||||
badgeLeftEl.style.left = W * 0.12 + "px"; // 230 px
|
||||
badgeLeftEl.style.top = H * 0.35 + "px"; // 378 px
|
||||
badgeLeftEl.style.position = "absolute";
|
||||
badgeRightEl.style.left = W * 0.75 + "px"; // 1440 px
|
||||
badgeRightEl.style.top = H * 0.38 + "px"; // 410 px
|
||||
badgeRightEl.style.position = "absolute";
|
||||
|
||||
/* ================================================================
|
||||
INITIAL STATES (via gsap.set, before the timeline runs)
|
||||
================================================================ */
|
||||
gsap.set("#title", { opacity: 0, y: -40 });
|
||||
|
||||
gsap.set("#card-left .card-pos", { x: -TIMING.slideDist, scale: 0.8, opacity: 0, y: 0 });
|
||||
gsap.set("#card-right .card-pos", { x: TIMING.slideDist, scale: 0.8, opacity: 0, y: 0 });
|
||||
gsap.set("#card-left .card-tilt", { rotationY: TIMING.baseTilt });
|
||||
gsap.set("#card-right .card-tilt", { rotationY: -TIMING.baseTilt });
|
||||
|
||||
gsap.set(["#badge-left", "#badge-right"], { scale: 0, opacity: 0, y: 0 });
|
||||
|
||||
/* ================================================================
|
||||
TIMELINE
|
||||
================================================================ */
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
window.__timelines["main"] = tl;
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
PHASE 1: Title slides down
|
||||
---------------------------------------------------------------- */
|
||||
tl.to(
|
||||
"#title",
|
||||
{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
duration: TIMING.titleDur,
|
||||
ease: "power3.out", // spring(stiffness:100, damping:16)
|
||||
},
|
||||
TIMING.titleAt,
|
||||
);
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
PHASE 2: Cards enter from opposite sides
|
||||
---------------------------------------------------------------- */
|
||||
tl.to(
|
||||
"#card-left .card-pos",
|
||||
{
|
||||
x: 0,
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
duration: TIMING.entryDur,
|
||||
ease: "power3.out",
|
||||
},
|
||||
TIMING.leftAt,
|
||||
);
|
||||
|
||||
tl.to(
|
||||
"#card-right .card-pos",
|
||||
{
|
||||
x: 0,
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
duration: TIMING.entryDur,
|
||||
ease: "power3.out",
|
||||
},
|
||||
TIMING.rightAt,
|
||||
);
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
PHASE 3: Badges pop in
|
||||
---------------------------------------------------------------- */
|
||||
tl.to(
|
||||
"#badge-left",
|
||||
{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
duration: TIMING.badgeEntryDur,
|
||||
ease: "back.out(1.7)",
|
||||
},
|
||||
TIMING.badgeLeftAt,
|
||||
);
|
||||
|
||||
tl.to(
|
||||
"#badge-right",
|
||||
{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
duration: TIMING.badgeEntryDur,
|
||||
ease: "back.out(1.7)",
|
||||
},
|
||||
TIMING.badgeRightAt,
|
||||
);
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
CONTINUOUS FLOATING — shared scene-ticker onUpdate
|
||||
Six gsap.set calls per frame, batched by the browser.
|
||||
---------------------------------------------------------------- */
|
||||
const leftPos = document.querySelector("#card-left .card-pos");
|
||||
const rightPos = document.querySelector("#card-right .card-pos");
|
||||
const leftTilt = document.querySelector("#card-left .card-tilt");
|
||||
const rightTilt = document.querySelector("#card-right .card-tilt");
|
||||
|
||||
tl.to(
|
||||
{ tick: 0 },
|
||||
{
|
||||
tick: 1,
|
||||
duration: TOTAL_DUR,
|
||||
ease: "none",
|
||||
onUpdate: function () {
|
||||
const t = tl.time();
|
||||
// Cards float in opposition (phase π apart).
|
||||
const lY = Math.sin(t * TIMING.floatYSpeed) * TIMING.floatYAmp;
|
||||
const lR = Math.sin(t * TIMING.floatRSpeed) * TIMING.floatRAmp;
|
||||
const rY = Math.sin(t * TIMING.floatYSpeed + Math.PI) * TIMING.floatYAmp;
|
||||
const rR = Math.sin(t * TIMING.floatRSpeed + Math.PI) * TIMING.floatRAmp;
|
||||
gsap.set(leftPos, { y: lY });
|
||||
gsap.set(rightPos, { y: rY });
|
||||
gsap.set(leftTilt, { rotationY: TIMING.baseTilt + lR });
|
||||
gsap.set(rightTilt, { rotationY: -TIMING.baseTilt + rR });
|
||||
|
||||
// Badges — small shared y oscillation.
|
||||
const bY = Math.sin(t * TIMING.badgeYSpeed) * TIMING.badgeYAmp;
|
||||
gsap.set(badgeLeftEl, { y: bY });
|
||||
gsap.set(badgeRightEl, { y: bY });
|
||||
},
|
||||
},
|
||||
0,
|
||||
);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,520 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=1920, height=1080" />
|
||||
<title>Scene 7 — HyperFrames Decrypt Pan Track</title>
|
||||
|
||||
<!--
|
||||
HyperFrames composition.tsx.
|
||||
|
||||
Choreography (4 phases, 7 seconds total):
|
||||
0.0 – 0.7s Shot 1 static text fades in + rises ("HyperFrames renders")
|
||||
0.7 – 1.6s Hacker-flip decodes the accent word ("video")
|
||||
2.8 – 3.5s Horizontal pan to Shot 2 with parallax exit + scale-in
|
||||
3.7 – 6.1s Cursor-tracked typing: "HTML, CSS and JS become MP4"
|
||||
(NOT @the source/layout-utils, NOT a charWidthRatio constant)
|
||||
- Bar width pre-allocated from the full text — never tweened
|
||||
- Camera follows cursor by tweening the strip's `x`, with a piecewise
|
||||
Math.min(initialOffset, trackingOffset) for the two-phase camera
|
||||
- Hacker-flip glyph flicker via deterministic int hash, not Math.random
|
||||
-->
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg: linear-gradient(
|
||||
135deg,
|
||||
#3a3a3a 0%,
|
||||
#17211f 30%,
|
||||
#0b2328 48%,
|
||||
#1f3518 70%,
|
||||
#343434 100%
|
||||
);
|
||||
--text-dark: #f8fafc;
|
||||
--text-highlight: #18d9e8;
|
||||
--text-highlight-2: #7bea5a;
|
||||
--search-bg: rgba(14, 24, 23, 0.82);
|
||||
--cursor: #7bea5a;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
font-family:
|
||||
"Inter",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
.viewport {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.strip {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
will-change: transform;
|
||||
}
|
||||
.shot {
|
||||
width: 1920px;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
SHOT 1 — "HyperFrames renders video"
|
||||
============================================================ */
|
||||
.shot1-content {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0; /* GSAP fromTo fades in */
|
||||
}
|
||||
.shot1-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4em;
|
||||
font-size: 130px;
|
||||
}
|
||||
.shot1-static {
|
||||
font-weight: 500;
|
||||
color: var(--text-dark);
|
||||
text-shadow: 0 0 36px rgba(24, 217, 232, 0.18);
|
||||
}
|
||||
.shot1-accent {
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
perspective: 800px; /* required for the per-glyph rotateX */
|
||||
}
|
||||
.flip-glyph {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
min-width: 0;
|
||||
}
|
||||
.flip-glyph.space {
|
||||
min-width: 0.4em;
|
||||
}
|
||||
.flip-glyph .ghost {
|
||||
opacity: 0;
|
||||
}
|
||||
.flip-glyph .anim {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
color: var(--text-highlight);
|
||||
background: linear-gradient(135deg, var(--text-highlight) 0%, var(--text-highlight-2) 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
filter: drop-shadow(0 0 24px rgba(24, 217, 232, 0.32))
|
||||
drop-shadow(0 0 36px rgba(123, 234, 90, 0.22));
|
||||
opacity: 0;
|
||||
transform: perspective(600px) rotateX(90deg);
|
||||
transform-origin: bottom;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
SHOT 2 — Search bar with cursor-tracked typing
|
||||
============================================================ */
|
||||
.shot2-bar {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
/* left set by JS once we know the bar's world coordinate */
|
||||
height: 240px;
|
||||
background: var(--search-bg);
|
||||
border: 1px solid rgba(123, 234, 90, 0.22);
|
||||
box-shadow:
|
||||
0 0 60px rgba(24, 217, 232, 0.2),
|
||||
0 0 96px rgba(123, 234, 90, 0.14),
|
||||
inset 0 0 32px rgba(24, 217, 232, 0.08);
|
||||
border-radius: 999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 120px;
|
||||
padding-right: 180px;
|
||||
opacity: 0; /* GSAP fromTo fades in */
|
||||
transform-origin: left center;
|
||||
}
|
||||
.search-text {
|
||||
font-size: 120px;
|
||||
font-weight: 400;
|
||||
color: var(--text-dark);
|
||||
text-shadow: 0 0 24px rgba(248, 250, 252, 0.16);
|
||||
white-space: pre;
|
||||
line-height: 1;
|
||||
}
|
||||
.search-cursor {
|
||||
font-size: 120px;
|
||||
color: var(--cursor);
|
||||
text-shadow: 0 0 26px rgba(123, 234, 90, 0.42);
|
||||
margin-left: 4px;
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Hidden probe used by measureNodeWidth */
|
||||
.measure-probe {
|
||||
position: absolute;
|
||||
left: -99999px;
|
||||
top: -99999px;
|
||||
visibility: hidden;
|
||||
white-space: pre;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="root"
|
||||
data-composition-id="main"
|
||||
data-start="0"
|
||||
data-duration="7"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
>
|
||||
<div class="viewport" data-layout-allow-overflow>
|
||||
<div class="strip" data-layout-allow-overflow>
|
||||
<!-- =====================================================
|
||||
SHOT 1 — "HyperFrames renders video"
|
||||
===================================================== -->
|
||||
<div
|
||||
id="shot-decode"
|
||||
class="shot shot1 clip"
|
||||
data-start="0"
|
||||
data-duration="3.5"
|
||||
data-track-index="1"
|
||||
>
|
||||
<div class="shot1-content">
|
||||
<div class="shot1-row">
|
||||
<span class="shot1-static">HyperFrames renders</span>
|
||||
<span class="shot1-accent" aria-label="video">
|
||||
<!-- .flip-glyph spans generated by JS -->
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- =====================================================
|
||||
SHOT 2 — Search bar typing
|
||||
===================================================== -->
|
||||
<div
|
||||
id="shot-typing"
|
||||
class="shot shot2 clip"
|
||||
data-start="2.5"
|
||||
data-duration="4.5"
|
||||
data-track-index="2"
|
||||
>
|
||||
<div class="shot2-bar">
|
||||
<span class="search-text"></span><span class="search-cursor">_</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ================================================================
|
||||
CONSTANTS — all baked at setup time.
|
||||
================================================================ */
|
||||
const W = 1920,
|
||||
H = 1080;
|
||||
const COMPOSITION_DURATION = 7;
|
||||
const FPS_HASH = 60; // synthetic clock for the flicker hash
|
||||
|
||||
const TIMING = {
|
||||
// Phase 1: shot1 text fades in + rises
|
||||
shot1EntryStart: 0.0,
|
||||
shot1EntryDur: 0.67,
|
||||
|
||||
// Phase 2: hacker-flip "video"
|
||||
flipStart: 0.7,
|
||||
flipStagger: 0.066, // ~2 frames at 30fps per glyph
|
||||
flipDuration: 0.55,
|
||||
|
||||
// Phase 3: horizontal pan
|
||||
panStart: 2.83,
|
||||
panDuration: 0.67,
|
||||
|
||||
// Phase 4: cursor-tracked typing
|
||||
typingStart: 3.67, // panStart + panDuration + 0.17 buffer
|
||||
charRate: 0.083, // seconds per character (~2.5 frames at 30fps)
|
||||
};
|
||||
|
||||
const FULL_TEXT = "HTML, CSS and JS become MP4";
|
||||
const FONT_SIZE = 120;
|
||||
const PADDING_LEFT = 120;
|
||||
const PADDING_RIGHT = 180;
|
||||
const CURSOR_VIS_W = FONT_SIZE * 0.6; // visual cursor width approximation
|
||||
const CURSOR_TARGET = W * 0.7; // screen X where cursor locks
|
||||
const BAR_LEFT_MARGIN = 80; // initial left margin in Phase 4
|
||||
const PARALLAX_DIST = 400; // px Shot 1 moves extra during pan
|
||||
|
||||
/* ================================================================
|
||||
BUILD DOM — flip glyphs (Shot 1) generated synchronously.
|
||||
The accent text is "video" → 5 glyphs.
|
||||
================================================================ */
|
||||
const accentEl = document.querySelector(".shot1-accent");
|
||||
const ACCENT_WORD = "video";
|
||||
ACCENT_WORD.split("").forEach((char, index) => {
|
||||
const span = document.createElement("span");
|
||||
span.className = "flip-glyph" + (char === " " ? " space" : "");
|
||||
span.dataset.char = char;
|
||||
span.dataset.index = String(index);
|
||||
// Each glyph needs its own font-size to match the surrounding row.
|
||||
span.style.fontSize = "130px";
|
||||
const ghost = document.createElement("span");
|
||||
ghost.className = "ghost";
|
||||
ghost.textContent = char === " " ? " " : char;
|
||||
const anim = document.createElement("span");
|
||||
anim.className = "anim";
|
||||
anim.textContent = char === " " ? " " : char;
|
||||
span.append(ghost, anim);
|
||||
accentEl.appendChild(span);
|
||||
});
|
||||
|
||||
/* ================================================================
|
||||
TEXT MEASUREMENT — uses a hidden DOM probe so we capture
|
||||
real letter-spacing, kerning, and font-feature widths.
|
||||
Must run AFTER document.fonts.ready.
|
||||
================================================================ */
|
||||
function measureNodeWidth(text, font) {
|
||||
const probe = document.createElement("span");
|
||||
probe.className = "measure-probe";
|
||||
probe.style.font = font;
|
||||
probe.style.whiteSpace = "pre";
|
||||
probe.textContent = text;
|
||||
document.body.appendChild(probe);
|
||||
const width = probe.getBoundingClientRect().width;
|
||||
probe.remove();
|
||||
return width;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
TIMELINE BUILD — fires after fonts are ready so measurements
|
||||
use the real rendered metrics, not fallback fonts.
|
||||
================================================================ */
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// Register early so HF can find it; tweens are added below.
|
||||
window.__timelines["main"] = tl;
|
||||
|
||||
document.fonts.ready.then(() => {
|
||||
const searchFont = `400 ${FONT_SIZE}px Inter, system-ui, sans-serif`;
|
||||
const fullTextWidth = measureNodeWidth(FULL_TEXT, searchFont);
|
||||
const barWidth = PADDING_LEFT + fullTextWidth + CURSOR_VIS_W + PADDING_RIGHT;
|
||||
|
||||
// Pre-allocate the bar's final width — no width tweens ever.
|
||||
const barEl = document.querySelector(".shot2-bar");
|
||||
barEl.style.width = barWidth + "px";
|
||||
|
||||
// Initial bar position in Shot 2's local space.
|
||||
// We position it at LEFT_MARGIN from the shot's left edge.
|
||||
barEl.style.left = BAR_LEFT_MARGIN + "px";
|
||||
barEl.style.transform = "translateY(-50%)"; // vertical center
|
||||
|
||||
// ============================================================
|
||||
// PHASE 1: Shot 1 text fade in + rise
|
||||
// ============================================================
|
||||
tl.fromTo(
|
||||
".shot1-content",
|
||||
{ opacity: 0, y: 30 },
|
||||
{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
duration: TIMING.shot1EntryDur,
|
||||
ease: "power2.out",
|
||||
},
|
||||
TIMING.shot1EntryStart,
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// PHASE 2: Hacker-flip "video"
|
||||
// ============================================================
|
||||
const CHAR_POOL = "abcdefghijklmnopqrstuvwxyz";
|
||||
const FLICKER = 3;
|
||||
const REVEAL_AT = 0.6;
|
||||
|
||||
// Deterministic 32-bit mix — replaces Math.random / seeded RNG.
|
||||
function pseudoHash(i, t) {
|
||||
return ((i * 374761393 + t * 668265263) >>> 0) % CHAR_POOL.length;
|
||||
}
|
||||
|
||||
document.querySelectorAll(".flip-glyph").forEach((glyph) => {
|
||||
const index = Number(glyph.dataset.index);
|
||||
const real = glyph.dataset.char === " " ? " " : glyph.dataset.char;
|
||||
const anim = glyph.querySelector(".anim");
|
||||
const start = TIMING.flipStart + index * TIMING.flipStagger;
|
||||
|
||||
tl.fromTo(
|
||||
anim,
|
||||
{ rotationX: 90, opacity: 0, "--p": 0 },
|
||||
{
|
||||
rotationX: 0,
|
||||
opacity: 1,
|
||||
"--p": 1,
|
||||
duration: TIMING.flipDuration,
|
||||
ease: "back.out(1.6)", // spring(stiffness:150, damping:14)
|
||||
onUpdate: function () {
|
||||
const p = Number(gsap.getProperty(anim, "--p"));
|
||||
if (p >= REVEAL_AT) {
|
||||
if (anim.textContent !== real) anim.textContent = real;
|
||||
} else {
|
||||
const localFrame = Math.floor((tl.time() - start) * FPS_HASH);
|
||||
const bucket = Math.max(0, Math.floor(localFrame / FLICKER));
|
||||
anim.textContent = CHAR_POOL[pseudoHash(index, bucket)];
|
||||
}
|
||||
},
|
||||
},
|
||||
start,
|
||||
);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// PHASE 3: Horizontal pan + parallax exit + Shot 2 entry
|
||||
// All three tweens at the same timeline position run in parallel.
|
||||
// ============================================================
|
||||
// (a) Camera pan — strip slides one full viewport left.
|
||||
tl.to(
|
||||
".strip",
|
||||
{
|
||||
x: -W,
|
||||
duration: TIMING.panDuration,
|
||||
ease: "power3.inOut", // cinematic slow-in-slow-out
|
||||
},
|
||||
TIMING.panStart,
|
||||
);
|
||||
|
||||
// (b) Shot 1 parallax exit — content moves EXTRA -PARALLAX_DIST.
|
||||
tl.to(
|
||||
".shot1-content",
|
||||
{
|
||||
x: -PARALLAX_DIST,
|
||||
duration: TIMING.panDuration,
|
||||
ease: "power3.inOut",
|
||||
},
|
||||
TIMING.panStart,
|
||||
);
|
||||
|
||||
// Shot 1 fades out partway through the pan so the eye lands on Shot 2.
|
||||
tl.to(
|
||||
".shot1-content",
|
||||
{
|
||||
opacity: 0,
|
||||
duration: TIMING.panDuration * 0.4,
|
||||
ease: "power2.out",
|
||||
},
|
||||
TIMING.panStart,
|
||||
);
|
||||
|
||||
// (c) Shot 2 bar entry — fade + scale with mild overshoot ("landing").
|
||||
tl.fromTo(
|
||||
".shot2-bar",
|
||||
{ opacity: 0, scale: 0.8 },
|
||||
{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
duration: TIMING.panDuration,
|
||||
ease: "back.out(1.2)",
|
||||
},
|
||||
TIMING.panStart,
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// PHASE 4: Cursor-tracked typing.
|
||||
//
|
||||
// The strip is now at x = -W (Shot 2 fully on screen). For the
|
||||
// cursor-track effect we'll *further* shift the strip by a piecewise
|
||||
// amount: hold initial offset while the empty bar's cursor is left
|
||||
// of CURSOR_TARGET, then follow once typing pushes it past.
|
||||
// ============================================================
|
||||
const searchTextEl = document.querySelector(".search-text");
|
||||
const stripEl = document.querySelector(".strip");
|
||||
|
||||
const CURSOR_WIDTH_HALF = CURSOR_VIS_W / 2;
|
||||
const STRIP_BASE_X = -W; // strip's x at the end of Phase 3
|
||||
|
||||
// Initial Phase-4 offset = 0 (no additional shift on top of -W).
|
||||
// i.e. bar already sits at BAR_LEFT_MARGIN inside Shot 2.
|
||||
const INITIAL_OFFSET = 0;
|
||||
|
||||
// Typing driver — a clock that runs from 0 to FULL_TEXT.length.
|
||||
const typingProxy = { progress: 0 };
|
||||
const typingDur = FULL_TEXT.length * TIMING.charRate;
|
||||
const typingEnd = TIMING.typingStart + typingDur;
|
||||
const postTypingHold = Math.max(0, COMPOSITION_DURATION - typingEnd);
|
||||
|
||||
tl.to(
|
||||
typingProxy,
|
||||
{
|
||||
progress: FULL_TEXT.length,
|
||||
duration: typingDur,
|
||||
ease: "none",
|
||||
onUpdate: function () {
|
||||
const charsTyped = Math.min(FULL_TEXT.length, Math.floor(typingProxy.progress));
|
||||
const visibleText = FULL_TEXT.slice(0, charsTyped);
|
||||
if (searchTextEl.textContent !== visibleText) {
|
||||
searchTextEl.textContent = visibleText;
|
||||
}
|
||||
|
||||
// Measure current visible width to compute cursor screen position.
|
||||
// Cheap because Inter's metrics are cached after the first call.
|
||||
const visibleW =
|
||||
visibleText.length === 0 ? 0 : measureNodeWidth(visibleText, searchFont);
|
||||
|
||||
// Cursor X in *Shot 2's* coordinate system:
|
||||
// shot left edge → bar left edge (BAR_LEFT_MARGIN)
|
||||
// → cursor within bar (PADDING_LEFT + visibleW + CURSOR_WIDTH_HALF)
|
||||
const cursorXInShot2 = BAR_LEFT_MARGIN + PADDING_LEFT + visibleW + CURSOR_WIDTH_HALF;
|
||||
|
||||
// For the cursor to land at CURSOR_TARGET on the *screen*, the
|
||||
// strip must be shifted by:
|
||||
// stripX = CURSOR_TARGET - cursorXInShot2 - W (the -W accounts
|
||||
// for Shot 2 being the SECOND shot in the strip)
|
||||
const trackingStripX = CURSOR_TARGET - cursorXInShot2 - W;
|
||||
|
||||
// Piecewise: hold STRIP_BASE_X + INITIAL_OFFSET until tracking
|
||||
// would pan FURTHER LEFT than the base, then follow.
|
||||
const finalStripX = Math.min(STRIP_BASE_X + INITIAL_OFFSET, trackingStripX);
|
||||
|
||||
gsap.set(stripEl, { x: finalStripX });
|
||||
},
|
||||
},
|
||||
TIMING.typingStart,
|
||||
);
|
||||
|
||||
if (postTypingHold > 0) {
|
||||
tl.to(
|
||||
".search-cursor",
|
||||
{
|
||||
opacity: 0.22,
|
||||
duration: postTypingHold / 2,
|
||||
repeat: 1,
|
||||
yoyo: true,
|
||||
ease: "none",
|
||||
},
|
||||
typingEnd,
|
||||
);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,468 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=1920, height=1080" />
|
||||
<title>Scene 10 — HyperFrames Morph Press Interact</title>
|
||||
|
||||
<!--
|
||||
HyperFrames composition.tsx.
|
||||
|
||||
Choreography (4 phases, 5.5 seconds total):
|
||||
0.17 – 0.64s Hero "HyperFrames" + logo asset fade up (opacity + y rise)
|
||||
0.17 – 5.50s Logo asset rotates ±4° continuously (sine onUpdate)
|
||||
2.17 – 2.62s Hero shrinks (1 → 0.6) and fades (1 → 0)
|
||||
2.17 – 2.62s "Build video from HTML" CTA pill pops in with back.out(2) overshoot
|
||||
2.33 – 2.66s CTA text fades + lifts into place (after container is recognizable)
|
||||
2.83s Cursor hard-cuts in at off-screen bottom-right
|
||||
2.83 – 3.83s Cursor approaches via spring path to (W/2 + 120, H/2 + 50)
|
||||
3.83 – 3.98s Click DOWN: both cursor and CTA compress to scale 0.9 (sync)
|
||||
4.17 – 4.42s Click UP: both return to scale 1.0 (sync)
|
||||
DOM + GSAP opacity tweens; z-index keeps incoming above outgoing
|
||||
- Press synchronization uses single GSAP target array ["#cta", "#cursor"]
|
||||
to guarantee perfectly identical tween values
|
||||
- Cursor opacity uses a 0.001-second fromTo for hard-cut step change
|
||||
- Logo uses a local static image asset referenced with a plain URL
|
||||
- Breathing rotation only on the logo, not on the whole hero (avoids
|
||||
conflict with the morph exit scale tween)
|
||||
-->
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--hf-bg: #050711;
|
||||
--hf-bg-2: #0b1020;
|
||||
--hf-ink: #f8fafc;
|
||||
--hf-muted: #cbd5e1;
|
||||
--hf-purple: #7c3aed;
|
||||
--hf-purple-soft: #a78bfa;
|
||||
--hf-cyan: #18d9e8;
|
||||
--hf-green: #22c55e;
|
||||
--hf-glass: rgba(248, 250, 252, 0.08);
|
||||
--white: #ffffff;
|
||||
}
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(ellipse at 50% 42%, rgba(24, 217, 232, 0.14), transparent 50%),
|
||||
radial-gradient(ellipse at 62% 48%, rgba(124, 58, 237, 0.2), transparent 56%),
|
||||
radial-gradient(ellipse at 42% 58%, rgba(34, 197, 94, 0.1), transparent 58%),
|
||||
linear-gradient(135deg, #050711 0%, #090b18 48%, #120717 100%);
|
||||
font-family:
|
||||
"Inter",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
color: var(--hf-ink);
|
||||
}
|
||||
|
||||
.stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
HERO LOCKUP (Phase 1 — exits during morph)
|
||||
============================================================ */
|
||||
.hero {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 54px;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
.hero-text {
|
||||
font-size: 220px;
|
||||
font-weight: 800;
|
||||
color: var(--hf-ink);
|
||||
letter-spacing: 0;
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
text-shadow:
|
||||
0 0 34px rgba(167, 139, 250, 0.18),
|
||||
0 0 72px rgba(24, 217, 232, 0.1);
|
||||
}
|
||||
.hero-text .brand-accent {
|
||||
color: var(--hf-purple-soft);
|
||||
text-shadow:
|
||||
0 0 34px rgba(124, 58, 237, 0.45),
|
||||
0 0 82px rgba(167, 139, 250, 0.22);
|
||||
}
|
||||
.hero-logo {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
margin-left: 8px;
|
||||
will-change: transform;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.hero-logo .logo-mark {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
border-radius: 36px;
|
||||
box-shadow:
|
||||
0 0 64px rgba(24, 217, 232, 0.32),
|
||||
0 0 112px rgba(34, 197, 94, 0.18),
|
||||
0 0 150px rgba(124, 58, 237, 0.12);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
CTA PILL (Phase 2 — pops in during morph)
|
||||
============================================================ */
|
||||
.cta {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(124, 58, 237, 0.96) 0%,
|
||||
rgba(24, 217, 232, 0.9) 48%,
|
||||
rgba(34, 197, 94, 0.96) 100%
|
||||
);
|
||||
padding: 70px 200px;
|
||||
border-radius: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(24, 217, 232, 0.55);
|
||||
box-shadow:
|
||||
0 24px 60px rgba(0, 0, 0, 0.42),
|
||||
0 0 72px rgba(24, 217, 232, 0.24),
|
||||
0 0 120px rgba(124, 58, 237, 0.24),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.24),
|
||||
inset 0 0 34px var(--hf-glass);
|
||||
will-change: transform, opacity;
|
||||
/* initial scale 0 + opacity 0 set by gsap.set() */
|
||||
}
|
||||
.cta-text {
|
||||
color: var(--hf-bg);
|
||||
font-size: 110px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
text-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.3),
|
||||
0 0 22px rgba(255, 255, 255, 0.12);
|
||||
will-change: transform, opacity;
|
||||
/* initial opacity 0 + y 10 set by gsap.set() */
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
CURSOR (Phase 3 — hard-cuts in)
|
||||
============================================================ */
|
||||
.cursor {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
will-change: transform, opacity;
|
||||
/* initial x/y/opacity set by gsap.set() */
|
||||
}
|
||||
.cursor .cursor-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="root"
|
||||
data-composition-id="main"
|
||||
data-start="0"
|
||||
data-duration="5.5"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
>
|
||||
<div class="stage clip" data-start="0" data-duration="5.5" data-track-index="1" id="stage">
|
||||
<!-- =====================================================
|
||||
HERO LOCKUP — "HyperFrames" + logo
|
||||
===================================================== -->
|
||||
<div
|
||||
class="hero clip"
|
||||
id="hero"
|
||||
data-start="0"
|
||||
data-duration="2.62"
|
||||
data-track-index="2"
|
||||
data-layout-allow-overflow
|
||||
aria-hidden="true"
|
||||
>
|
||||
<h1 class="hero-text">Hyper<span class="brand-accent">Frames</span></h1>
|
||||
<div class="hero-logo" id="hero-logo">
|
||||
<!-- Inline-SVG placeholder mark — swap for your logo image -->
|
||||
<svg class="logo-mark" viewBox="0 0 100 100" role="img" aria-label="HyperFrames">
|
||||
<defs>
|
||||
<linearGradient id="hfMark" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#8b7bff" />
|
||||
<stop offset="1" stop-color="#3ddc97" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="4" y="4" width="92" height="92" rx="22" fill="url(#hfMark)" />
|
||||
<text
|
||||
x="50"
|
||||
y="63"
|
||||
text-anchor="middle"
|
||||
font-family="Inter, system-ui, sans-serif"
|
||||
font-size="40"
|
||||
font-weight="800"
|
||||
fill="#fff"
|
||||
>
|
||||
HF
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- =====================================================
|
||||
CTA PILL — "Build video from HTML"
|
||||
===================================================== -->
|
||||
<div class="cta" id="cta">
|
||||
<span class="cta-text">Build video from HTML</span>
|
||||
</div>
|
||||
|
||||
<!-- =====================================================
|
||||
CURSOR — inline SVG arrow with drop-shadow
|
||||
===================================================== -->
|
||||
<div class="cursor" id="cursor">
|
||||
<svg
|
||||
class="cursor-svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<filter id="cursorShadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feDropShadow dx="2" dy="4" stdDeviation="3" flood-opacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
<path
|
||||
d="M5.5 3.5L19 10.5L11.5 12.5L15.5 19.5L13.5 20.5L9.5 13.5L5.5 17.5V3.5Z"
|
||||
fill="#050711"
|
||||
stroke="#f8fafc"
|
||||
stroke-width="1.5"
|
||||
stroke-linejoin="round"
|
||||
filter="url(#cursorShadow)"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ================================================================
|
||||
TIMING
|
||||
================================================================ */
|
||||
const W = 1920,
|
||||
H = 1080;
|
||||
const TOTAL_DUR = 5.5;
|
||||
|
||||
const TIMING = {
|
||||
introStart: 0.17, // ~5 frames at 30 fps
|
||||
introDur: 0.47, // hero enter spring settle
|
||||
|
||||
morphAt: 2.17, // ~65 frames
|
||||
morphExitDur: 0.5,
|
||||
morphFadeDur: 0.15, // 30% of exit
|
||||
morphEntDur: 0.45, // CTA pop-in
|
||||
|
||||
textRevealAt: 2.33, // morph + 0.17 s
|
||||
textRevealDur: 0.33,
|
||||
|
||||
cursorEnterAt: 2.83,
|
||||
cursorPathDur: 1.0,
|
||||
|
||||
clickDownAt: 3.83,
|
||||
clickDownDur: 0.15,
|
||||
clickUpAt: 4.17,
|
||||
clickUpDur: 0.25,
|
||||
|
||||
pressIntensity: 0.1,
|
||||
};
|
||||
|
||||
/* ================================================================
|
||||
INITIAL STATES (via gsap.set, before the timeline runs)
|
||||
================================================================ */
|
||||
// Hero: starts at opacity 0, y +40 (will fade up)
|
||||
gsap.set("#hero", { opacity: 0, y: 40 });
|
||||
|
||||
// CTA: starts invisible (scale 0, opacity 0) — morph pops it in
|
||||
gsap.set("#cta", { scale: 0, opacity: 0 });
|
||||
|
||||
// CTA text: starts at opacity 0 + y 10 — reveals after container is recognizable
|
||||
gsap.set(".cta-text", { opacity: 0, y: 10 });
|
||||
|
||||
// Cursor: starts off-screen bottom-right + invisible + scale 1 baseline
|
||||
gsap.set("#cursor", {
|
||||
x: W + 100,
|
||||
y: H + 200,
|
||||
scale: 1,
|
||||
opacity: 0,
|
||||
});
|
||||
|
||||
/* ================================================================
|
||||
TIMELINE
|
||||
================================================================ */
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
window.__timelines["main"] = tl;
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
PHASE 1: Hero entrance + breathing rotation on logo
|
||||
---------------------------------------------------------------- */
|
||||
tl.to(
|
||||
"#hero",
|
||||
{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
duration: TIMING.introDur,
|
||||
ease: "power3.out", // spring(stiffness:120, damping:14)
|
||||
},
|
||||
TIMING.introStart,
|
||||
);
|
||||
|
||||
// Continuous breathing rotation on the logo. ±4° over ~6.3 s period.
|
||||
// For a 5.5 s composition this is just under one cycle — subtle but alive.
|
||||
// Use Form 2 (onUpdate reading tl.time) per sine-wave-loop rule.
|
||||
const logoEl = document.querySelector("#hero-logo");
|
||||
tl.to(
|
||||
{ tick: 0 },
|
||||
{
|
||||
tick: 1,
|
||||
duration: TOTAL_DUR,
|
||||
ease: "none",
|
||||
onUpdate: function () {
|
||||
const t = tl.time();
|
||||
gsap.set(logoEl, { rotation: Math.sin(t * 1.0) * 4 });
|
||||
},
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
PHASE 2: Scale-swap morph
|
||||
Three concurrent tween clusters from MORPH_AT.
|
||||
---------------------------------------------------------------- */
|
||||
// (1) Hero shrinks
|
||||
tl.to(
|
||||
"#hero",
|
||||
{
|
||||
scale: 0.6,
|
||||
duration: TIMING.morphExitDur,
|
||||
ease: "power3.out", // spring(stiffness:150, damping:18)
|
||||
},
|
||||
TIMING.morphAt,
|
||||
);
|
||||
|
||||
// (2) Hero fades fast (30% of shrink dur)
|
||||
tl.to(
|
||||
"#hero",
|
||||
{
|
||||
opacity: 0,
|
||||
duration: TIMING.morphFadeDur,
|
||||
ease: "power2.out",
|
||||
},
|
||||
TIMING.morphAt,
|
||||
);
|
||||
|
||||
tl.set(
|
||||
"#hero",
|
||||
{
|
||||
x: -4000,
|
||||
},
|
||||
TIMING.morphAt + TIMING.morphFadeDur,
|
||||
);
|
||||
|
||||
// (3) CTA pops in with overshoot
|
||||
tl.to(
|
||||
"#cta",
|
||||
{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
duration: TIMING.morphEntDur,
|
||||
ease: "back.out(2)", // spring(stiffness:200, damping:15, mass:0.6)
|
||||
},
|
||||
TIMING.morphAt,
|
||||
);
|
||||
|
||||
// (4) CTA text reveals after container reaches recognizable scale
|
||||
tl.to(
|
||||
".cta-text",
|
||||
{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
duration: TIMING.textRevealDur,
|
||||
ease: "power2.out",
|
||||
},
|
||||
TIMING.textRevealAt,
|
||||
);
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
PHASE 3: Cursor entry + motion path
|
||||
---------------------------------------------------------------- */
|
||||
// Hard-cut opacity (cursors don't fade in — they appear).
|
||||
// 0.001 s tween creates a step change that scrubs correctly.
|
||||
tl.fromTo(
|
||||
"#cursor",
|
||||
{ opacity: 0 },
|
||||
{ opacity: 1, duration: 0.001, ease: "none" },
|
||||
TIMING.cursorEnterAt,
|
||||
);
|
||||
|
||||
// Spring-driven approach to a target slightly offset from center
|
||||
// (where a human would naturally aim — not dead-center).
|
||||
tl.to(
|
||||
"#cursor",
|
||||
{
|
||||
x: W / 2 + 120,
|
||||
y: H / 2 + 50,
|
||||
duration: TIMING.cursorPathDur,
|
||||
ease: "power2.out", // spring(stiffness:60, damping:20)
|
||||
},
|
||||
TIMING.cursorEnterAt,
|
||||
);
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
PHASE 4: Physics-based press
|
||||
Both CTA and cursor compress together — single target array.
|
||||
---------------------------------------------------------------- */
|
||||
// Press DOWN — both elements compress to (1 - intensity)
|
||||
tl.to(
|
||||
["#cta", "#cursor"],
|
||||
{
|
||||
scale: 1 - TIMING.pressIntensity,
|
||||
duration: TIMING.clickDownDur,
|
||||
ease: "power3.out", // spring(stiffness:300, damping:20)
|
||||
},
|
||||
TIMING.clickDownAt,
|
||||
);
|
||||
|
||||
// RELEASE — back to scale 1.0
|
||||
tl.to(
|
||||
["#cta", "#cursor"],
|
||||
{
|
||||
scale: 1.0,
|
||||
duration: TIMING.clickUpDur,
|
||||
ease: "power2.out", // spring(stiffness:200, damping:15)
|
||||
},
|
||||
TIMING.clickUpAt,
|
||||
);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,759 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=1920, height=1080" />
|
||||
<title>Scene 04 — Contextual Product Showcase</title>
|
||||
|
||||
<!--
|
||||
HyperFrames composition.tsx.
|
||||
|
||||
Choreography (4 phases, 9 seconds total):
|
||||
0.00 – 0.80s 3D-tilted page card scales 0.95 → 1.0
|
||||
Navbar / title / CTA fade in (staggered)
|
||||
0.12 – 2.80s Title keywords glow synced to ASR words:
|
||||
"1" "long" "video" "10" "viral" "clips"
|
||||
3.08 – 4.08s Page content scrolls up 280 px (programmatic scroll feel)
|
||||
Carousel section fades in + scales from 0.9
|
||||
3.58 – 8.84s Main video pops forward in 3D (translateZ 80 px) + scales up
|
||||
Radial spotlight dims the surroundings; decays at end
|
||||
(attack 0→1 then decay 1→REST_LEVEL). All visual effects (color,
|
||||
text-shadow, scale) derive from --glow via CSS calc()
|
||||
- Pop-target uses the same --glow pattern; translateZ + scale derived
|
||||
- Spotlight is a separate overlay with simple opacity tween
|
||||
- Video placeholders are colored divs (no asset files needed)
|
||||
- Tilt is static CSS, never tweened
|
||||
-->
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--page-bg: #0a0a0f;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #a1a1aa;
|
||||
--accent: #edcb50;
|
||||
--input-bg: #1a1a1f;
|
||||
--green: #22c55e;
|
||||
}
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
font-family:
|
||||
"Inter",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
BACKGROUND
|
||||
============================================================ */
|
||||
.bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(
|
||||
ellipse 100% 60% at 50% -10%,
|
||||
rgba(100, 100, 150, 0.08) 0%,
|
||||
transparent 60%
|
||||
),
|
||||
var(--page-bg);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
3D PERSPECTIVE + TILTED CARD
|
||||
============================================================ */
|
||||
.perspective-wrap {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
perspective: 1200px;
|
||||
}
|
||||
.page-card {
|
||||
width: 92%;
|
||||
height: 88%;
|
||||
background-color: var(--page-bg);
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
transform-style: preserve-3d;
|
||||
/* tilt + initial scale set by gsap.set() below so subsequent tweens preserve them */
|
||||
box-shadow:
|
||||
-30px 30px 60px rgba(0, 0, 0, 0.4),
|
||||
-15px 15px 30px rgba(0, 0, 0, 0.3),
|
||||
0 0 80px rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
NAVBAR (sticks to top of card; NOT inside scroll content)
|
||||
============================================================ */
|
||||
.page-navbar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 72px;
|
||||
padding: 0 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
z-index: 100;
|
||||
opacity: 0; /* GSAP fades in */
|
||||
background: linear-gradient(to bottom, rgba(10, 10, 15, 0.95), rgba(10, 10, 15, 0.6));
|
||||
}
|
||||
.nav-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.nav-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.nav-brand {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.nav-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
}
|
||||
.nav-item {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.nav-item .chev {
|
||||
margin-left: 4px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.nav-tag {
|
||||
font-size: 10px;
|
||||
background: var(--green);
|
||||
color: #fff;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.nav-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.nav-signin {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.nav-signup {
|
||||
padding: 8px 16px;
|
||||
background: var(--accent);
|
||||
border-radius: 8px;
|
||||
color: var(--page-bg);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
SCROLL CONTENT — GSAP tweens its y
|
||||
============================================================ */
|
||||
.scroll-content {
|
||||
padding-top: 140px;
|
||||
padding-bottom: 60px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
HERO SECTION
|
||||
============================================================ */
|
||||
.hero {
|
||||
text-align: center;
|
||||
max-width: 1200px;
|
||||
width: 90%;
|
||||
opacity: 0; /* GSAP fades in */
|
||||
}
|
||||
.hero-eyebrow {
|
||||
font-size: 14px;
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
margin-bottom: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.hero-title {
|
||||
font-size: 64px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
.hero-sub {
|
||||
font-size: 20px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin-top: 24px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
max-width: 800px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
KEYWORD GLOW — all derived from --glow CSS custom property
|
||||
============================================================ */
|
||||
.kw {
|
||||
--glow: 0;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
padding: 0 0.015em;
|
||||
color: rgb(
|
||||
calc(255 - var(--glow) * 18) calc(255 - var(--glow) * 30) calc(255 - var(--glow) * 150)
|
||||
);
|
||||
text-shadow:
|
||||
0 0 calc(var(--glow) * 10px) rgba(255, 235, 120, calc(var(--glow) * 0.95)),
|
||||
0 0 calc(var(--glow) * 24px) rgba(237, 203, 80, calc(var(--glow) * 0.78)),
|
||||
0 0 calc(var(--glow) * 44px) rgba(237, 203, 80, calc(var(--glow) * 0.38));
|
||||
transform: scale(calc(1 + var(--glow) * 0.035));
|
||||
}
|
||||
.kw::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -0.025em;
|
||||
right: -0.025em;
|
||||
top: 0.08em;
|
||||
bottom: 0.05em;
|
||||
z-index: -1;
|
||||
border-radius: 0.16em;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(237, 203, 80, 0.08),
|
||||
rgba(255, 222, 80, 0.72),
|
||||
rgba(237, 203, 80, 0.12)
|
||||
);
|
||||
opacity: calc(var(--glow) * 0.78);
|
||||
transform: scaleX(calc(0.72 + var(--glow) * 0.24)) skewX(-5deg);
|
||||
transform-origin: left center;
|
||||
box-shadow:
|
||||
0 0 calc(var(--glow) * 14px) rgba(237, 203, 80, calc(var(--glow) * 0.7)),
|
||||
0 0 calc(var(--glow) * 28px) rgba(237, 203, 80, calc(var(--glow) * 0.32));
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
CTA INPUT ROW
|
||||
============================================================ */
|
||||
.cta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin-top: 36px;
|
||||
opacity: 0; /* GSAP fades in */
|
||||
}
|
||||
.cta-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--input-bg);
|
||||
border-radius: 28px;
|
||||
padding: 6px 6px 6px 18px;
|
||||
gap: 12px;
|
||||
}
|
||||
.cta-link-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
.cta-placeholder {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
min-width: 130px;
|
||||
}
|
||||
.cta-button {
|
||||
padding: 12px 24px;
|
||||
background: #fff;
|
||||
border-radius: 22px;
|
||||
color: var(--page-bg);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.cta-or {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 14px;
|
||||
}
|
||||
.cta-upload {
|
||||
padding: 12px 24px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 22px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
VIDEO CAROUSEL (Phase 2 entry, Phase 4 pop-out)
|
||||
============================================================ */
|
||||
.carousel-wrap {
|
||||
opacity: 0; /* GSAP fades in */
|
||||
transform: scale(0.9); /* GSAP scales to 1 */
|
||||
transform-origin: center top;
|
||||
margin-top: 48px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
padding: 0 20px;
|
||||
perspective: 800px;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
.carousel-arrow {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: rgba(60, 60, 65, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
/* Main video — Phase 4 pop-out target */
|
||||
.carousel-main {
|
||||
--glow: 0;
|
||||
width: 720px;
|
||||
height: 420px;
|
||||
background: var(--input-bg);
|
||||
border-radius: 16px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
transform-style: preserve-3d;
|
||||
transform: translateZ(calc(var(--glow) * 80px)) scale(calc(1 + var(--glow) * 0.15));
|
||||
box-shadow:
|
||||
0 0 calc(var(--glow) * 25px) rgba(237, 203, 80, calc(var(--glow) * 0.7)),
|
||||
0 0 calc(var(--glow) * 50px) rgba(237, 203, 80, calc(var(--glow) * 0.4)),
|
||||
0 calc(20px + var(--glow) * 40px) calc(60px + var(--glow) * 40px)
|
||||
rgba(0, 0, 0, calc(0.6 + var(--glow) * 0.2));
|
||||
border: 3px solid rgba(237, 203, 80, calc(var(--glow) * 0.8));
|
||||
z-index: 200;
|
||||
}
|
||||
.carousel-main-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
background: linear-gradient(135deg, #1a2540 0%, #3d2a4f 50%, #4f1d2e 100%);
|
||||
}
|
||||
.carousel-main-placeholder span {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
padding: 8px 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Side panel */
|
||||
.carousel-side {
|
||||
width: 420px;
|
||||
height: 280px;
|
||||
background: var(--input-bg);
|
||||
border-radius: 16px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.carousel-side-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, #2a3550 0%, #4a3a5a 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.presets-stack {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
z-index: 5;
|
||||
}
|
||||
.preset-label {
|
||||
padding: 8px 14px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.preset-tile {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
SPOTLIGHT OVERLAY (above page card, dims surroundings)
|
||||
============================================================ */
|
||||
.spotlight {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(
|
||||
ellipse 850px 550px at 40% 60%,
|
||||
transparent 0%,
|
||||
transparent 50%,
|
||||
rgba(0, 0, 0, 0.65) 100%
|
||||
);
|
||||
pointer-events: none;
|
||||
opacity: 0; /* GSAP fades in during Phase 4 */
|
||||
z-index: 150;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
VIGNETTE
|
||||
============================================================ */
|
||||
.vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse at center, transparent 50%, rgba(0, 0, 0, 0.3) 100%);
|
||||
pointer-events: none;
|
||||
z-index: 400;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="root"
|
||||
data-composition-id="main"
|
||||
data-start="0"
|
||||
data-duration="9"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
>
|
||||
<div class="bg"></div>
|
||||
|
||||
<div class="perspective-wrap">
|
||||
<div
|
||||
class="page-card clip"
|
||||
data-start="0"
|
||||
data-duration="9"
|
||||
data-track-index="1"
|
||||
id="page-card"
|
||||
>
|
||||
<!-- Navbar (anchored to card top) -->
|
||||
<nav class="page-navbar" id="navbar">
|
||||
<div class="nav-left">
|
||||
<svg
|
||||
class="nav-logo"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M11.954 3.817c-4.49 0-8.132 3.644-8.152 8.146v12H0V12C0 5.373 5.352 0 11.954 0s11.953 5.373 11.953 12-5.351 12-11.953 12a12 12 0 0 1-1.718-.123v-3.876q.831.181 1.718.182c4.502 0 8.152-3.663 8.152-8.183s-3.65-8.183-8.152-8.183"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.118 24V11.995c0-3.79 3.062-6.857 6.836-6.857 3.773 0 6.836 3.068 6.836 6.857s-3.063 6.857-6.836 6.857c-.594 0-1.17-.076-1.718-.218V14.5c.488.337 1.08.534 1.718.534a3.037 3.037 0 0 0 3.034-3.04 3.037 3.037 0 0 0-3.034-3.04 3.037 3.037 0 0 0-3.034 3.008V24z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="nav-brand">OpusClip</span>
|
||||
</div>
|
||||
<div class="nav-center">
|
||||
<span class="nav-item">Features<span class="chev">▾</span></span>
|
||||
<span class="nav-item">Solutions<span class="chev">▾</span></span>
|
||||
<span class="nav-item">Resources<span class="chev">▾</span></span>
|
||||
<span class="nav-item">Pricing</span>
|
||||
<span class="nav-item">For business<span class="chev">▾</span></span>
|
||||
<span class="nav-item"><span class="nav-tag">New</span>Agent Opus</span>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<span class="nav-signin">Sign in</span>
|
||||
<span class="nav-signup">Sign up - It's FREE</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Scrollable content -->
|
||||
<div class="scroll-content" id="scroll-content">
|
||||
<div class="hero" id="hero">
|
||||
<div class="hero-eyebrow">#1 AI VIDEO CLIPPING TOOL</div>
|
||||
<h1 class="hero-title">
|
||||
<span class="kw" data-glow-start="0.12" data-glow-end="0.28">1</span>
|
||||
<span class="kw" data-glow-start="0.52" data-glow-end="0.72">long</span>
|
||||
<span class="kw" data-glow-start="0.78" data-glow-end="1.44">video,</span>
|
||||
<span class="kw" data-glow-start="1.48" data-glow-end="1.80">10</span>
|
||||
<span class="kw" data-glow-start="1.94" data-glow-end="2.22">viral</span>
|
||||
<span class="kw" data-glow-start="2.28" data-glow-end="2.80">clips.</span>
|
||||
<br />Create 10x faster.
|
||||
</h1>
|
||||
<p class="hero-sub">
|
||||
OpusClip turns long videos into shorts, and publishes them to all social platforms
|
||||
in one click.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="cta-row" id="cta-row">
|
||||
<div class="cta-input">
|
||||
<svg
|
||||
class="cta-link-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
<span class="cta-placeholder">Drop a video link</span>
|
||||
<div class="cta-button">Get free clips</div>
|
||||
</div>
|
||||
<span class="cta-or">or</span>
|
||||
<div class="cta-upload">Upload files</div>
|
||||
</div>
|
||||
|
||||
<!-- Video carousel section -->
|
||||
<div class="carousel-wrap" id="carousel">
|
||||
<div class="carousel-arrow">‹</div>
|
||||
|
||||
<div class="carousel-main pop-target" id="pop-target">
|
||||
<div class="carousel-main-placeholder">
|
||||
<span>Hero animation</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="carousel-arrow">›</div>
|
||||
|
||||
<div class="carousel-side">
|
||||
<div class="carousel-side-placeholder">Demo · reframe</div>
|
||||
<div class="presets-stack">
|
||||
<div class="preset-label">Presets</div>
|
||||
<div class="preset-tile"></div>
|
||||
<div class="preset-tile"></div>
|
||||
<div class="preset-tile"></div>
|
||||
<div class="preset-tile"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /.scroll-content -->
|
||||
</div>
|
||||
<!-- /.page-card -->
|
||||
</div>
|
||||
<!-- /.perspective-wrap -->
|
||||
|
||||
<div class="spotlight" id="spotlight"></div>
|
||||
<div class="vignette"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ================================================================
|
||||
TIMING — all in local seconds (scene starts at 0).
|
||||
ASR timestamps are direct from the source, with SCENE_START = 16
|
||||
subtracted to get local time.
|
||||
================================================================ */
|
||||
const ASR = {
|
||||
// Phase 2 title keywords
|
||||
one: { start: 0.119, end: 0.279 },
|
||||
long: { start: 0.52, end: 0.719 },
|
||||
video: { start: 0.779, end: 1.44 },
|
||||
ten: { start: 1.479, end: 1.799 },
|
||||
viral: { start: 1.94, end: 2.219 },
|
||||
clips: { start: 2.279, end: 2.8 },
|
||||
};
|
||||
|
||||
const TIMING = {
|
||||
// Phase 1: entry
|
||||
cardEntryStart: 0.0,
|
||||
cardEntryDur: 0.8,
|
||||
navbarFadeAt: 0.0,
|
||||
navbarFadeDur: 0.6,
|
||||
titleFadeAt: 0.17,
|
||||
titleFadeDur: 0.7,
|
||||
ctaFadeAt: 0.5,
|
||||
ctaFadeDur: 0.7,
|
||||
|
||||
// Phase 2: keyword glows handled per-element via data attrs
|
||||
|
||||
// Phase 3: scroll + carousel
|
||||
phase2StartAt: 3.08,
|
||||
scrollDur: 1.0,
|
||||
scrollDistance: 280,
|
||||
carouselFadeDur: 0.5,
|
||||
|
||||
// Phase 4: pop-out + spotlight
|
||||
popStart: 3.58,
|
||||
popAttackDur: 0.67, // ramp 0 → 1
|
||||
popDecayDur: 0.33, // ramp 1 → 0.5 near end
|
||||
popEnd: 8.84,
|
||||
popRestLevel: 0.5,
|
||||
};
|
||||
|
||||
const KEYWORD_REST_LEVEL = 0.14;
|
||||
const KEYWORD_SUSTAIN = 0.5; // seconds after ASR end before decay completes
|
||||
|
||||
/* ================================================================
|
||||
TIMELINE
|
||||
================================================================ */
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
window.__timelines["main"] = tl;
|
||||
|
||||
// Set the static 3D tilt + initial scale via GSAP so subsequent
|
||||
// scale tweens preserve the rotation aliases. (If we left
|
||||
// `transform: rotateY(...) rotateX(...) scale(0.95)` in CSS, a GSAP
|
||||
// tween touching only `scale` could overwrite the full matrix and
|
||||
// lose the rotation. Owning the transform state in GSAP avoids the
|
||||
// problem entirely.)
|
||||
gsap.set(".page-card", {
|
||||
rotationY: -8,
|
||||
rotationX: 3,
|
||||
scale: 0.95,
|
||||
});
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
PHASE 1: Page card entry + navbar/title/CTA fade-in
|
||||
---------------------------------------------------------------- */
|
||||
tl.to(
|
||||
".page-card",
|
||||
{ scale: 1.0, duration: TIMING.cardEntryDur, ease: "power2.out" },
|
||||
TIMING.cardEntryStart,
|
||||
);
|
||||
|
||||
tl.fromTo(
|
||||
"#navbar",
|
||||
{ opacity: 0 },
|
||||
{ opacity: 1, duration: TIMING.navbarFadeDur, ease: "power2.out" },
|
||||
TIMING.navbarFadeAt,
|
||||
);
|
||||
|
||||
tl.fromTo(
|
||||
"#hero",
|
||||
{ opacity: 0 },
|
||||
{ opacity: 1, duration: TIMING.titleFadeDur, ease: "power2.out" },
|
||||
TIMING.titleFadeAt,
|
||||
);
|
||||
|
||||
tl.fromTo(
|
||||
"#cta-row",
|
||||
{ opacity: 0 },
|
||||
{ opacity: 1, duration: TIMING.ctaFadeDur, ease: "power2.out" },
|
||||
TIMING.ctaFadeAt,
|
||||
);
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
PHASE 2: Keyword glows — two tweens per word.
|
||||
Attack (0 → 1) + decay (1 → REST_LEVEL). GSAP holds REST forever.
|
||||
---------------------------------------------------------------- */
|
||||
document.querySelectorAll(".kw").forEach((kw) => {
|
||||
const start = Number(kw.dataset.glowStart);
|
||||
const end = Number(kw.dataset.glowEnd);
|
||||
const peak = start + (end - start) / 2;
|
||||
const restAt = end + KEYWORD_SUSTAIN;
|
||||
|
||||
tl.fromTo(
|
||||
kw,
|
||||
{ "--glow": 0 },
|
||||
{ "--glow": 1.18, duration: peak - start, ease: "power2.out" },
|
||||
start,
|
||||
);
|
||||
|
||||
tl.to(
|
||||
kw,
|
||||
{ "--glow": KEYWORD_REST_LEVEL, duration: restAt - peak, ease: "power2.out" },
|
||||
peak,
|
||||
);
|
||||
});
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
PHASE 3: Scroll + carousel entry
|
||||
---------------------------------------------------------------- */
|
||||
tl.fromTo(
|
||||
"#scroll-content",
|
||||
{ y: 0 },
|
||||
{ y: -TIMING.scrollDistance, duration: TIMING.scrollDur, ease: "power2.inOut" },
|
||||
TIMING.phase2StartAt,
|
||||
);
|
||||
|
||||
tl.fromTo(
|
||||
"#carousel",
|
||||
{ opacity: 0, scale: 0.9 },
|
||||
{ opacity: 1, scale: 1.0, duration: TIMING.carouselFadeDur, ease: "power2.out" },
|
||||
TIMING.phase2StartAt,
|
||||
);
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
PHASE 4: Pop-out (driven by --glow on .carousel-main) + spotlight
|
||||
---------------------------------------------------------------- */
|
||||
// Attack the pop-out glow.
|
||||
tl.fromTo(
|
||||
"#pop-target",
|
||||
{ "--glow": 0 },
|
||||
{ "--glow": 1, duration: TIMING.popAttackDur, ease: "power2.out" },
|
||||
TIMING.popStart,
|
||||
);
|
||||
|
||||
// Decay at the end of the scene (mirrors original phase2End-10 / phase2End ramp).
|
||||
tl.to(
|
||||
"#pop-target",
|
||||
{ "--glow": TIMING.popRestLevel, duration: TIMING.popDecayDur, ease: "power2.out" },
|
||||
TIMING.popEnd - TIMING.popDecayDur,
|
||||
);
|
||||
|
||||
// Spotlight fades in alongside the pop attack, decays with the pop.
|
||||
tl.fromTo(
|
||||
"#spotlight",
|
||||
{ opacity: 0 },
|
||||
{ opacity: 1, duration: 0.5, ease: "power2.out" },
|
||||
TIMING.popStart,
|
||||
);
|
||||
|
||||
tl.to(
|
||||
"#spotlight",
|
||||
{ opacity: TIMING.popRestLevel, duration: TIMING.popDecayDur, ease: "power2.out" },
|
||||
TIMING.popEnd - TIMING.popDecayDur,
|
||||
);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,729 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=1920, height=1080" />
|
||||
<title>Scene 01 — Counting Icon Burst</title>
|
||||
|
||||
<!--
|
||||
HyperFrames composition.tsx.
|
||||
|
||||
Choreography (4 phases, 3.5 seconds total):
|
||||
0.00 – 0.17s Background visible with dark overlay; nothing else
|
||||
0.17 – 0.57s Four enriched icons enter staggered, clustered at startOffset 0.4
|
||||
(clock 0.17s · scissors 0.30s · video 0.43s · play 0.57s)
|
||||
0.47 – 1.47s Counter "0 → 90" with growing font size (0.20W → 0.42W);
|
||||
icons expand outward from 40% to 100% position
|
||||
0.50 – 2.33s Camera focus phase: scale 0.92 → 1.0
|
||||
2.33 – 3.50s Camera push phase: scale 1.0 → 1.08
|
||||
1.27s Percent symbol pops in with own spring
|
||||
|
||||
Continuous motion (running from t=0, gated by icon visibility):
|
||||
Clock minute hand: linear rotation 420° over 3.5s
|
||||
Scissors: ±15° sine oscillation, period ~1.7s
|
||||
Cutting line: stroke-dashoffset drifts -200 over 3.5s
|
||||
Video record dot: opacity + scale phase-offset sine pulses
|
||||
Play triangle: scale pulse ±8%, period ~2.6s
|
||||
ease + duration (no shared driver needed, just identical timing)
|
||||
- Counter text + number scale are emitted as seek-safe timeline keyframes
|
||||
- Background: local Pexels MP4 plus dark overlay
|
||||
- Per-icon entry uses GSAP fromTo on a nested .icon-entry wrapper
|
||||
so it never overwrites the .icon-pos expansion tween
|
||||
-->
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg-dark: #060812;
|
||||
--text-primary: #ffffff;
|
||||
--accent-yellow: #edcb50;
|
||||
--record-red: #ef4444;
|
||||
}
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-dark);
|
||||
font-family:
|
||||
"Inter",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
BACKGROUND (static gradient)
|
||||
============================================================ */
|
||||
.bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform-origin: center center;
|
||||
will-change: transform;
|
||||
background:
|
||||
radial-gradient(ellipse at 30% 30%, rgba(80, 100, 200, 0.25), transparent 60%),
|
||||
radial-gradient(ellipse at 70% 70%, rgba(180, 80, 150, 0.2), transparent 60%),
|
||||
linear-gradient(135deg, #0a0d1f 0%, #1a0d20 100%);
|
||||
}
|
||||
.bg-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.66);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
CAMERA WRAPPER (GSAP-managed scale phases)
|
||||
============================================================ */
|
||||
.camera {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform-origin: center center;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
ICONS — two nested wrappers per icon.
|
||||
.icon-pos is positioned at TARGET; GSAP x/y shifts toward center.
|
||||
.icon-entry tweens scale/opacity/rotation.
|
||||
============================================================ */
|
||||
.icons-stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.icon-pos {
|
||||
position: absolute;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
will-change: transform;
|
||||
}
|
||||
.icon-entry {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
will-change: transform, opacity;
|
||||
/* initial scale(0) + opacity(0) set by GSAP fromTo */
|
||||
}
|
||||
.icon-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* SVG inner elements — class hooks for GSAP tweens */
|
||||
.clock-hand-min {
|
||||
transform-box: view-box;
|
||||
}
|
||||
.scissor-upper,
|
||||
.scissor-lower {
|
||||
transform-origin: 12px 12px;
|
||||
}
|
||||
.play-tri {
|
||||
transform-origin: 12px 12px;
|
||||
}
|
||||
.record-dot {
|
||||
transform-origin: 19px 8px;
|
||||
}
|
||||
.play-ring {
|
||||
transform-origin: 12px 12px;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
COUNTER (absolute center, 3D entry)
|
||||
============================================================ */
|
||||
.counter-stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
perspective: 2000px;
|
||||
perspective-origin: center center;
|
||||
}
|
||||
.counter-3d {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
transform-style: preserve-3d;
|
||||
will-change: transform, opacity;
|
||||
opacity: 0; /* GSAP entry fades in */
|
||||
}
|
||||
.counter-number {
|
||||
font-size: 806px; /* W × 0.42 — final size, GSAP scales from 0.20W */
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 0.9;
|
||||
font-variant-numeric: tabular-nums; /* prevents 1→2 digit jitter */
|
||||
color: var(--text-primary);
|
||||
display: inline-block;
|
||||
transform-origin: center bottom;
|
||||
will-change: transform;
|
||||
}
|
||||
.counter-percent {
|
||||
font-size: 269px; /* W × 0.14 */
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--accent-yellow);
|
||||
margin-left: 16px;
|
||||
display: inline-block;
|
||||
will-change: transform, opacity;
|
||||
opacity: 0; /* GSAP entry tweens in later */
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
VIGNETTE
|
||||
============================================================ */
|
||||
.vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse at center, transparent 50%, rgba(0, 0, 0, 0.5) 100%);
|
||||
pointer-events: none;
|
||||
z-index: 400;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="root"
|
||||
data-composition-id="main"
|
||||
data-start="0"
|
||||
data-duration="3.5"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
>
|
||||
<div class="bg"></div>
|
||||
<div class="bg-overlay"></div>
|
||||
|
||||
<div
|
||||
class="camera clip"
|
||||
data-start="0"
|
||||
data-duration="3.5"
|
||||
data-track-index="1"
|
||||
id="camera-stage"
|
||||
>
|
||||
<div class="icons-stage">
|
||||
<!-- Clock -->
|
||||
<div class="icon-pos clock-pos" id="clock-pos">
|
||||
<div class="icon-entry clock-entry">
|
||||
<svg
|
||||
class="icon-svg"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
class="clock-ring"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="9"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
stroke-dasharray="56.5"
|
||||
stroke-dashoffset="56.5"
|
||||
/>
|
||||
<line
|
||||
x1="12"
|
||||
y1="12"
|
||||
x2="12"
|
||||
y2="8"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<line
|
||||
class="clock-hand-min"
|
||||
x1="12"
|
||||
y1="12"
|
||||
x2="12"
|
||||
y2="6"
|
||||
stroke="#edcb50"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="1.5" fill="#edcb50" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scissors -->
|
||||
<div class="icon-pos scissors-pos" id="scissors-pos">
|
||||
<div class="icon-entry scissors-entry">
|
||||
<svg
|
||||
class="icon-svg"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g class="scissor-upper">
|
||||
<path
|
||||
d="M12 10.5L4.5 3L6.5 3L14 10.5"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
<circle cx="6" cy="6" r="2.5" fill="none" stroke="white" stroke-width="1.5" />
|
||||
</g>
|
||||
<g class="scissor-lower">
|
||||
<path
|
||||
d="M12 13.5L4.5 21L6.5 21L14 13.5"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
<circle cx="6" cy="18" r="2.5" fill="none" stroke="white" stroke-width="1.5" />
|
||||
</g>
|
||||
<line
|
||||
class="cutting-line"
|
||||
x1="14"
|
||||
y1="12"
|
||||
x2="20"
|
||||
y2="12"
|
||||
stroke="#edcb50"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="4 2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video -->
|
||||
<div class="icon-pos video-pos" id="video-pos">
|
||||
<div class="icon-entry video-entry">
|
||||
<svg
|
||||
class="icon-svg"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect
|
||||
class="video-frame"
|
||||
x="3"
|
||||
y="5"
|
||||
width="14"
|
||||
height="14"
|
||||
rx="2"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
fill="none"
|
||||
stroke-dasharray="56"
|
||||
stroke-dashoffset="56"
|
||||
/>
|
||||
<path d="M8 9L13 12L8 15V9Z" fill="white" />
|
||||
<circle class="record-dot" cx="19" cy="8" r="2.5" fill="#ef4444" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Play -->
|
||||
<div class="icon-pos play-pos" id="play-pos">
|
||||
<div class="icon-entry play-entry">
|
||||
<svg
|
||||
class="icon-svg"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
class="play-ring"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="9"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
stroke-dasharray="56.5"
|
||||
stroke-dashoffset="56.5"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="7" fill="rgba(255,255,255,0.05)" />
|
||||
<path class="play-tri" d="M10 8L16 12L10 16V8Z" fill="#edcb50" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /.icons-stage -->
|
||||
|
||||
<div class="counter-stage">
|
||||
<div class="counter-3d" id="counter-3d">
|
||||
<span class="counter-number" id="counter-number">0</span
|
||||
><span class="counter-percent" id="counter-percent">%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /.camera -->
|
||||
|
||||
<div class="vignette"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ================================================================
|
||||
CONSTANTS
|
||||
================================================================ */
|
||||
const W = 1920,
|
||||
H = 1080;
|
||||
const ICON_SIZE = 180;
|
||||
const CENTER_X = W / 2 - ICON_SIZE / 2; // 870
|
||||
const CENTER_Y = H / 2 - ICON_SIZE / 2; // 450
|
||||
|
||||
const TIMING = {
|
||||
// Phase 2 — icon entries (staggered)
|
||||
clockEntryAt: 0.17,
|
||||
scissorsEntryAt: 0.3,
|
||||
videoEntryAt: 0.43,
|
||||
playEntryAt: 0.57,
|
||||
entryDur: 0.55,
|
||||
|
||||
// Phase 3 — count + expansion (shared start, dur, ease)
|
||||
countAt: 0.47,
|
||||
countDur: 1.0,
|
||||
startOffset: 0.4,
|
||||
|
||||
// Counter 3D entry (slightly earlier than count starts)
|
||||
counterEntryAt: 0.27,
|
||||
counterEntryDur: 0.7,
|
||||
|
||||
// Percent symbol entry
|
||||
percentEntryAt: 1.27,
|
||||
percentEntryDur: 0.55,
|
||||
|
||||
// Phase 4 — camera
|
||||
cameraFocusAt: 0.5,
|
||||
cameraFocusDur: 1.83,
|
||||
cameraPushAt: 2.33,
|
||||
cameraPushDur: 1.17,
|
||||
};
|
||||
|
||||
const ICONS = [
|
||||
{
|
||||
name: "clock",
|
||||
sel: "#clock-pos",
|
||||
targetX: W * 0.06,
|
||||
targetY: H * 0.18,
|
||||
delay: TIMING.clockEntryAt,
|
||||
entryRotation: -180,
|
||||
entryEase: "back.out(1.5)",
|
||||
},
|
||||
{
|
||||
name: "scissors",
|
||||
sel: "#scissors-pos",
|
||||
targetX: W * 0.85,
|
||||
targetY: H * 0.2,
|
||||
delay: TIMING.scissorsEntryAt,
|
||||
entryRotation: 0,
|
||||
entryEase: "back.out(1.5)",
|
||||
},
|
||||
{
|
||||
name: "video",
|
||||
sel: "#video-pos",
|
||||
targetX: W * 0.04,
|
||||
targetY: H * 0.68,
|
||||
delay: TIMING.videoEntryAt,
|
||||
entryRotation: 0,
|
||||
entryEase: "back.out(1.4)",
|
||||
},
|
||||
{
|
||||
name: "play",
|
||||
sel: "#play-pos",
|
||||
targetX: W * 0.88,
|
||||
targetY: H * 0.65,
|
||||
delay: TIMING.playEntryAt,
|
||||
entryRotation: 90,
|
||||
entryEase: "back.out(1.5)",
|
||||
},
|
||||
];
|
||||
|
||||
/* ================================================================
|
||||
SET ICON TARGET POSITIONS + INITIAL EXPANSION OFFSETS
|
||||
================================================================ */
|
||||
ICONS.forEach(({ sel, targetX, targetY }) => {
|
||||
const el = document.querySelector(sel);
|
||||
el.style.left = targetX + "px";
|
||||
el.style.top = targetY + "px";
|
||||
|
||||
// Initial offset for START_OFFSET = 0.4:
|
||||
// position = target + (center - target) * (1 - 0.4) = target + 0.6 * (center - target)
|
||||
const offsetX = (CENTER_X - targetX) * (1 - TIMING.startOffset);
|
||||
const offsetY = (CENTER_Y - targetY) * (1 - TIMING.startOffset);
|
||||
gsap.set(el, { x: offsetX, y: offsetY });
|
||||
});
|
||||
|
||||
/* Set initial entry state on each .icon-entry */
|
||||
ICONS.forEach(({ name, entryRotation }) => {
|
||||
gsap.set(`.${name}-entry`, {
|
||||
scale: 0,
|
||||
opacity: 0,
|
||||
rotation: entryRotation,
|
||||
});
|
||||
});
|
||||
|
||||
/* Set initial background and camera scale + subtle pan baseline */
|
||||
gsap.set(".bg", { scale: 1.05 });
|
||||
gsap.set(".camera", { scale: 0.92, x: 0, y: 2 });
|
||||
gsap.set("#counter-number", { scale: 0.2 / 0.42 });
|
||||
|
||||
/* ================================================================
|
||||
TIMELINE
|
||||
================================================================ */
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
window.__timelines["main"] = tl;
|
||||
|
||||
tl.to(
|
||||
".bg",
|
||||
{
|
||||
scale: 1.07,
|
||||
x: 6,
|
||||
y: -4,
|
||||
duration: 3.5,
|
||||
ease: "sine.inOut",
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
PHASE 2: Icon entries (staggered)
|
||||
---------------------------------------------------------------- */
|
||||
ICONS.forEach(({ name, delay, entryEase }) => {
|
||||
// Scale + opacity + rotation entry
|
||||
tl.to(
|
||||
`.${name}-entry`,
|
||||
{
|
||||
scale: 1,
|
||||
opacity: 0.85,
|
||||
rotation: 0,
|
||||
duration: TIMING.entryDur,
|
||||
ease: entryEase,
|
||||
},
|
||||
delay,
|
||||
);
|
||||
});
|
||||
|
||||
// Stroke-draw the outline circles/rect on clock, video, and play during their entries
|
||||
tl.to(
|
||||
".clock-ring",
|
||||
{
|
||||
attr: { "stroke-dashoffset": 0 },
|
||||
duration: TIMING.entryDur,
|
||||
ease: "power2.out",
|
||||
},
|
||||
TIMING.clockEntryAt,
|
||||
);
|
||||
|
||||
tl.to(
|
||||
".video-frame",
|
||||
{
|
||||
attr: { "stroke-dashoffset": 0 },
|
||||
duration: TIMING.entryDur,
|
||||
ease: "power2.out",
|
||||
},
|
||||
TIMING.videoEntryAt,
|
||||
);
|
||||
|
||||
tl.to(
|
||||
".play-ring",
|
||||
{
|
||||
attr: { "stroke-dashoffset": 0 },
|
||||
duration: TIMING.entryDur,
|
||||
ease: "power2.out",
|
||||
},
|
||||
TIMING.playEntryAt,
|
||||
);
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
PHASE 3: Counter (with 3D entry) + expansion
|
||||
---------------------------------------------------------------- */
|
||||
// Counter 3D entry — translateZ + rotateY + scale + opacity, in parallel.
|
||||
tl.fromTo(
|
||||
"#counter-3d",
|
||||
{ rotationY: 10, scale: 0.7, z: -300, opacity: 0 },
|
||||
{
|
||||
rotationY: 0,
|
||||
scale: 1.0,
|
||||
z: 0,
|
||||
opacity: 1,
|
||||
duration: TIMING.counterEntryDur,
|
||||
ease: "power3.out",
|
||||
},
|
||||
TIMING.counterEntryAt,
|
||||
);
|
||||
|
||||
// Counter number + apparent size. Use discrete frame keyframes so
|
||||
// HyperFrames can seek directly to any sampled time.
|
||||
const counterEl = document.querySelector("#counter-number");
|
||||
const COUNT_STEPS = 30;
|
||||
const COUNT_START_SCALE = 0.2 / 0.42;
|
||||
|
||||
for (let i = 0; i <= COUNT_STEPS; i += 1) {
|
||||
const p = i / COUNT_STEPS;
|
||||
const eased = 1 - Math.pow(1 - p, 2.5);
|
||||
const number = Math.round(eased * 90);
|
||||
const scale = COUNT_START_SCALE + eased * (1 - COUNT_START_SCALE);
|
||||
|
||||
tl.set(
|
||||
counterEl,
|
||||
{
|
||||
textContent: String(number),
|
||||
scale,
|
||||
},
|
||||
TIMING.countAt + p * TIMING.countDur,
|
||||
);
|
||||
}
|
||||
|
||||
// Percent symbol enters later with its own spring.
|
||||
tl.fromTo(
|
||||
"#counter-percent",
|
||||
{ x: 60, rotation: 20, scale: 0.4, opacity: 0 },
|
||||
{
|
||||
x: 0,
|
||||
rotation: 0,
|
||||
scale: 1.0,
|
||||
opacity: 1,
|
||||
duration: TIMING.percentEntryDur,
|
||||
ease: "back.out(1.4)",
|
||||
},
|
||||
TIMING.percentEntryAt,
|
||||
);
|
||||
|
||||
// Icon expansion — per-icon, same start/dur/ease as counter for lock-step sync.
|
||||
ICONS.forEach(({ sel }) => {
|
||||
tl.to(
|
||||
sel,
|
||||
{
|
||||
x: 0,
|
||||
y: 0,
|
||||
duration: TIMING.countDur,
|
||||
ease: "power2.out",
|
||||
},
|
||||
TIMING.countAt,
|
||||
);
|
||||
});
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
PHASE 4: Multi-phase camera
|
||||
---------------------------------------------------------------- */
|
||||
tl.to(
|
||||
".camera",
|
||||
{
|
||||
scale: 1.0,
|
||||
duration: TIMING.cameraFocusDur,
|
||||
ease: "power2.out",
|
||||
},
|
||||
TIMING.cameraFocusAt,
|
||||
);
|
||||
|
||||
tl.to(
|
||||
".camera",
|
||||
{
|
||||
scale: 1.08,
|
||||
duration: TIMING.cameraPushDur,
|
||||
ease: "power2.out",
|
||||
},
|
||||
TIMING.cameraPushAt,
|
||||
);
|
||||
|
||||
// Subtle deterministic pan from the the source camera micro-movement.
|
||||
tl.to(
|
||||
".camera",
|
||||
{
|
||||
x: 3,
|
||||
y: 1.6,
|
||||
duration: 3.5,
|
||||
ease: "sine.inOut",
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
CONTINUOUS INTERNAL SVG MOTION
|
||||
All four icons' inner animations are derived from tl.time() in
|
||||
a single shared onUpdate. Cheaper than 4-5 separate onUpdates,
|
||||
easier to inspect.
|
||||
---------------------------------------------------------------- */
|
||||
const SCISSOR_SPEED = 0.12 * 30; // = 3.6 rad/sec (sin freq)
|
||||
const SCISSOR_AMP = 15;
|
||||
const REC_OPACITY_SPEED = 0.15 * 30; // = 4.5 rad/sec
|
||||
const REC_SCALE_SPEED = 0.1 * 30; // = 3.0 rad/sec
|
||||
const REC_OPACITY_AMP = 0.3;
|
||||
const REC_OPACITY_BASE = 0.7;
|
||||
const REC_SCALE_AMP = 0.15;
|
||||
const PLAY_PULSE_SPEED = 0.08 * 30; // = 2.4 rad/sec
|
||||
const PLAY_PULSE_AMP = 0.08;
|
||||
const CUTTING_FLOW_SPEED = 0.5 * 30; // = 15 units/sec (for stroke-dashoffset)
|
||||
|
||||
const scissorUpper = document.querySelector(".scissor-upper");
|
||||
const scissorLower = document.querySelector(".scissor-lower");
|
||||
const recordDot = document.querySelector(".record-dot");
|
||||
const playTri = document.querySelector(".play-tri");
|
||||
const cuttingLine = document.querySelector(".cutting-line");
|
||||
|
||||
tl.to(
|
||||
{ tick: 0 },
|
||||
{
|
||||
tick: 1,
|
||||
duration: 3.5,
|
||||
ease: "none",
|
||||
onUpdate: function () {
|
||||
const t = tl.time();
|
||||
|
||||
// Scissors — symmetric oscillation, anchored to scissors delay
|
||||
const sciT = t - TIMING.scissorsEntryAt;
|
||||
const sciAngle = sciT > 0 ? Math.sin(sciT * SCISSOR_SPEED) * SCISSOR_AMP : 0;
|
||||
gsap.set(scissorUpper, { rotation: sciAngle });
|
||||
gsap.set(scissorLower, { rotation: -sciAngle });
|
||||
|
||||
// Cutting line — linear dash flow (negative = leftward drift)
|
||||
const cutT = t - TIMING.scissorsEntryAt;
|
||||
if (cutT > 0) {
|
||||
gsap.set(cuttingLine, {
|
||||
attr: { "stroke-dashoffset": -cutT * CUTTING_FLOW_SPEED },
|
||||
});
|
||||
}
|
||||
|
||||
// Record dot — phase-offset opacity and scale
|
||||
const recT = t - TIMING.videoEntryAt;
|
||||
if (recT > 0) {
|
||||
const recOpacity =
|
||||
Math.sin(recT * REC_OPACITY_SPEED) * REC_OPACITY_AMP + REC_OPACITY_BASE;
|
||||
const recScale = 1 + Math.sin(recT * REC_SCALE_SPEED) * REC_SCALE_AMP;
|
||||
gsap.set(recordDot, { opacity: recOpacity, scale: recScale });
|
||||
}
|
||||
|
||||
// Play triangle — gentle scale pulse
|
||||
const playT = t - TIMING.playEntryAt;
|
||||
if (playT > 0) {
|
||||
const playScale = 1 + Math.sin(playT * PLAY_PULSE_SPEED) * PLAY_PULSE_AMP;
|
||||
gsap.set(playTri, { scale: playScale });
|
||||
}
|
||||
},
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
/* Clock minute hand — linear rotation, anchored to clock entry */
|
||||
tl.fromTo(
|
||||
".clock-hand-min",
|
||||
{ rotation: 0, svgOrigin: "12 12" },
|
||||
{
|
||||
rotation: 420, // 120 deg/sec × 3.5s, scaled for visible motion
|
||||
svgOrigin: "12 12",
|
||||
duration: 3.5 - TIMING.clockEntryAt,
|
||||
ease: "none",
|
||||
},
|
||||
TIMING.clockEntryAt,
|
||||
);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,352 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=1920, height=1080" />
|
||||
<title>Scene — HyperFrames Messaging Multi-Phrase</title>
|
||||
|
||||
<!--
|
||||
Blueprint: messaging-multi-phrase (HyperFrames preview)
|
||||
HyperFrames-native version of the scene-9-multi-phrase concept.
|
||||
|
||||
Choreography (3 phrases, ~7.5 seconds total, content-driven duration):
|
||||
|
||||
Phrase 1 [0.00 – 3.86 s]
|
||||
"With an AI assistant " (21 chars × 0.083 s = 1.75 s)
|
||||
+ "you can trust" (13 chars × 0.083 s = 1.08 s, accent pink)
|
||||
+ hold 1.00 s
|
||||
→ total 1.75 + 1.08 + 1.00 ≈ 3.83 s (computed by JS)
|
||||
|
||||
Phrase 2 [3.86 – 5.27 s]
|
||||
"Instant " (8 chars × 0.083 s = 0.67 s)
|
||||
+ "insights" (8 chars × 0.083 s = 0.67 s, accent pink)
|
||||
+ hold 1.00 s
|
||||
→ total 0.67 + 0.67 + 1.00 ≈ 2.33 s, but starts at 3.86 → endTime ≈ 6.18 s
|
||||
|
||||
Phrase 3 [6.18 – 9.45 s]
|
||||
"Endless " (8 chars × 0.083 = 0.67 s)
|
||||
+ "possibilities" (13 chars × 0.083 = 1.08 s, accent pink)
|
||||
+ hold 2.00 s (longer — closing beat)
|
||||
→ total 0.67 + 1.08 + 2.00 ≈ 3.75 s, but starts at 6.18 → endTime ≈ 9.93 s
|
||||
|
||||
Continuous: cursor blink (square wave via `tl.time() % 1.0`)
|
||||
color, cursor blink. No per-phrase tweens.
|
||||
- No conditional DOM — phrase container exists from t=0 with empty text;
|
||||
onUpdate overwrites textContent in-place at each frame.
|
||||
- TIMELINE is a plain const computed once at script load (no useMemo).
|
||||
- Frames become seconds: charSpeed 2.5 frames → 0.083 s; hold 30 frames → 1.0 s.
|
||||
- Cursor blink: `(t % BLINK_CYCLE) < BLINK_CYCLE / 2 ? 1 : 0` inside the same
|
||||
onUpdate, not a separate component with `useCurrentFrame`.
|
||||
- textContent !== visible guard skips redundant DOM writes during hold windows.
|
||||
-->
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--text-primary: #ffffff;
|
||||
--text-accent: #32fff6;
|
||||
--text-accent-2: #a3ff7a;
|
||||
--bg: linear-gradient(
|
||||
135deg,
|
||||
#3a3a3a 0%,
|
||||
#17211f 30%,
|
||||
#0b2328 48%,
|
||||
#1f3518 70%,
|
||||
#343434 100%
|
||||
);
|
||||
}
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
font-family:
|
||||
"Inter",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
sans-serif;
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: geometricPrecision;
|
||||
}
|
||||
|
||||
.stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
PHRASE STAGE — one shared line with three inline children:
|
||||
.phrase-main primary-color portion
|
||||
.phrase-accent accent-color portion
|
||||
.phrase-cursor block cursor (color overridden per-frame)
|
||||
`white-space: pre` keeps the trailing space in textMain so the
|
||||
accent doesn't merge with the lead-in.
|
||||
============================================================ */
|
||||
.phrase-stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 150px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
white-space: pre;
|
||||
text-align: center;
|
||||
text-shadow: 0 0 10px rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
.phrase-main {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.phrase-accent {
|
||||
color: var(--text-accent);
|
||||
text-shadow: 0 0 12px rgba(50, 255, 246, 0.28);
|
||||
}
|
||||
.phrase-cursor {
|
||||
display: inline-block;
|
||||
flex: 0 0 auto;
|
||||
width: 8px;
|
||||
height: 162px; /* ≈ 1.08 × fontSize */
|
||||
background: var(--text-primary);
|
||||
margin-left: 12px;
|
||||
vertical-align: middle;
|
||||
transform: translateY(10px); /* baseline alignment */
|
||||
will-change: opacity, background-color;
|
||||
box-shadow: 0 0 12px rgba(50, 255, 246, 0.36);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
BACKGROUND TINT — subtle radial glow so pure black doesn't
|
||||
feel sterile. Drops to ~rgb(8,8,12) at corners.
|
||||
============================================================ */
|
||||
.bg-tint {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse at 42% 48%, rgba(24, 217, 232, 0.32), transparent 42%),
|
||||
radial-gradient(ellipse at 58% 52%, rgba(123, 234, 90, 0.26), transparent 44%),
|
||||
radial-gradient(ellipse at 50% 50%, rgba(8, 16, 18, 0.42), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="root"
|
||||
data-composition-id="main"
|
||||
data-start="0"
|
||||
data-duration="7.5"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
>
|
||||
<div
|
||||
id="multi-phrase-scene"
|
||||
class="stage clip"
|
||||
data-start="0"
|
||||
data-duration="7.5"
|
||||
data-track-index="1"
|
||||
>
|
||||
<div class="bg-tint"></div>
|
||||
<div class="phrase-stage">
|
||||
<span class="phrase-main"></span><span class="phrase-accent"></span
|
||||
><span class="phrase-cursor"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
/* ============================================================
|
||||
SCRIPT — content + per-phrase timing config.
|
||||
Change SCRIPT and the timeline reshapes itself.
|
||||
============================================================ */
|
||||
const SCRIPT = [
|
||||
{
|
||||
textMain: "Build video with ",
|
||||
textAccent: "HTML",
|
||||
charSpeed: 0.083, // seconds per character (= 2.5 frames @ 30 fps)
|
||||
hold: 1.0,
|
||||
},
|
||||
{
|
||||
textMain: "Seek ",
|
||||
textAccent: "any frame",
|
||||
charSpeed: 0.083,
|
||||
hold: 1.0,
|
||||
},
|
||||
{
|
||||
textMain: "Render to ",
|
||||
textAccent: "MP4",
|
||||
charSpeed: 0.083,
|
||||
hold: 2.0, // longer closing beat
|
||||
},
|
||||
];
|
||||
|
||||
/* ============================================================
|
||||
TIMELINE — flat reduce, computed once.
|
||||
Each entry gains absolute startTime + endTime in seconds.
|
||||
============================================================ */
|
||||
let acc = 0;
|
||||
const TIMELINE = SCRIPT.map((item) => {
|
||||
const totalChars = item.textMain.length + item.textAccent.length;
|
||||
const typingDuration = totalChars * item.charSpeed;
|
||||
const totalDuration = typingDuration + item.hold;
|
||||
const start = acc;
|
||||
const end = start + totalDuration;
|
||||
acc = end;
|
||||
return Object.assign({}, item, {
|
||||
startTime: start,
|
||||
endTime: end,
|
||||
typingDuration,
|
||||
});
|
||||
});
|
||||
const TOTAL = TIMELINE[TIMELINE.length - 1].endTime;
|
||||
// TOTAL ≈ 9.93s with the SCRIPT above. The composition root caps at 7.5s
|
||||
// (data-duration), which is the actual render window — phrases 1+2 fit
|
||||
// comfortably; phrase 3 holds long enough that any time after ~6.18s is
|
||||
// already on the closing line. Adjust SCRIPT durations to fit your budget.
|
||||
|
||||
/* ============================================================
|
||||
COLORS & BLINK
|
||||
============================================================ */
|
||||
const MAIN_COLOR = "#FFFFFF";
|
||||
const ACCENT_COLOR = "#32FFF6";
|
||||
const BLINK_CYCLE = 1.0; // seconds — 0.5s on, 0.5s off (matches 30-frame square wave)
|
||||
|
||||
const mainEl = document.querySelector(".phrase-main");
|
||||
const accentEl = document.querySelector(".phrase-accent");
|
||||
const cursorEl = document.querySelector(".phrase-cursor");
|
||||
|
||||
/* ============================================================
|
||||
MASTER ENGINE — single onUpdate writes text + cursor every frame.
|
||||
============================================================ */
|
||||
// Cache the most-recently-active phrase index for seek-safe scans.
|
||||
let lastIdx = 0;
|
||||
|
||||
function findPhrase(t) {
|
||||
// Hot path: same phrase still active
|
||||
if (t >= TIMELINE[lastIdx].startTime && t < TIMELINE[lastIdx].endTime) {
|
||||
return TIMELINE[lastIdx];
|
||||
}
|
||||
// Cold path: linear scan (handles forward + backward seeks)
|
||||
for (let i = 0; i < TIMELINE.length; i++) {
|
||||
if (t >= TIMELINE[i].startTime && t < TIMELINE[i].endTime) {
|
||||
lastIdx = i;
|
||||
return TIMELINE[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderAt(t) {
|
||||
// ----- Cursor blink (square wave, runs always) -----
|
||||
cursorEl.style.opacity = t % BLINK_CYCLE < BLINK_CYCLE / 2 ? "1" : "0";
|
||||
|
||||
// ----- Find current phrase -----
|
||||
const phrase = findPhrase(t);
|
||||
if (!phrase) {
|
||||
if (mainEl.textContent !== "") mainEl.textContent = "";
|
||||
if (accentEl.textContent !== "") accentEl.textContent = "";
|
||||
cursorEl.style.background = MAIN_COLOR;
|
||||
return;
|
||||
}
|
||||
|
||||
// ----- Compute visible characters -----
|
||||
const activeT = t - phrase.startTime;
|
||||
const charIdx = Math.floor(activeT / phrase.charSpeed);
|
||||
const mainLen = phrase.textMain.length;
|
||||
|
||||
const visMain = phrase.textMain.slice(0, Math.min(charIdx, mainLen));
|
||||
const accentLen = Math.max(0, charIdx - mainLen);
|
||||
const visAccent = phrase.textAccent.slice(0, accentLen);
|
||||
|
||||
// ----- DOM writes (guarded) -----
|
||||
if (mainEl.textContent !== visMain) mainEl.textContent = visMain;
|
||||
if (accentEl.textContent !== visAccent) accentEl.textContent = visAccent;
|
||||
|
||||
// ----- Cursor color follows active segment -----
|
||||
const inAccent = visMain.length === mainLen && visAccent.length > 0;
|
||||
cursorEl.style.background = inAccent ? ACCENT_COLOR : MAIN_COLOR;
|
||||
}
|
||||
|
||||
tl.to(
|
||||
{ tick: 0 },
|
||||
{
|
||||
tick: 1,
|
||||
duration: 7.5, // matches data-duration; clips the timeline
|
||||
ease: "none",
|
||||
onUpdate: () => renderAt(tl.time()),
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
const pauseTimeline = tl.pause.bind(tl);
|
||||
tl.pause = (time, suppressEvents) => {
|
||||
const result = pauseTimeline(time, suppressEvents);
|
||||
if (typeof time === "number") renderAt(time);
|
||||
return result;
|
||||
};
|
||||
|
||||
function wrapSeekOwner(owner) {
|
||||
if (!owner || owner.__multiPhraseSeekWrapped) return;
|
||||
let currentSeek = owner.seek;
|
||||
Object.defineProperty(owner, "seek", {
|
||||
configurable: true,
|
||||
get: () => currentSeek,
|
||||
set: (value) => {
|
||||
if (typeof value !== "function") {
|
||||
currentSeek = value;
|
||||
return;
|
||||
}
|
||||
currentSeek = function (time) {
|
||||
const result = value.apply(this, arguments);
|
||||
if (typeof time === "number") renderAt(time);
|
||||
return result;
|
||||
};
|
||||
},
|
||||
});
|
||||
owner.seek = currentSeek;
|
||||
owner.__multiPhraseSeekWrapped = true;
|
||||
}
|
||||
|
||||
function installWindowSeekHook(key) {
|
||||
if (window[key]) {
|
||||
wrapSeekOwner(window[key]);
|
||||
return;
|
||||
}
|
||||
let pendingOwner;
|
||||
Object.defineProperty(window, key, {
|
||||
configurable: true,
|
||||
get: () => pendingOwner,
|
||||
set: (value) => {
|
||||
pendingOwner = value;
|
||||
wrapSeekOwner(value);
|
||||
},
|
||||
});
|
||||
}
|
||||
installWindowSeekHook("__player");
|
||||
installWindowSeekHook("__hf");
|
||||
|
||||
renderAt(0);
|
||||
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,779 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=1920, height=1080" />
|
||||
<title>Scene 07 — HyperFrames Video Kinetic Text Pivot</title>
|
||||
|
||||
<!--
|
||||
HyperFrames composition.tsx.
|
||||
|
||||
Choreography (4 phases, 6.5 seconds total):
|
||||
0.17 – 0.87s "HyperFrames" badge fades in (top)
|
||||
0.17 – 0.87s Demo screen card enters centered (scale 0.6 → 1)
|
||||
2.20 – 3.00s Video slides left to 32% W (scale → 0.85); MP4 pops in on right
|
||||
with 5-layer green depth stack and bouncy back.out(1.6)
|
||||
3.00 – 3.86s MP4 breathes (±2% scale at ~1.9 Hz)
|
||||
3.86 – 4.46s Video and MP4 slide off left + fade; typing stage fades in center
|
||||
4.36 – 5.13s "HTML pages become video" types char-by-char (23 chars at 30 ch/s)
|
||||
Accent words "pages" and "video" in brand green
|
||||
4.83 – 5.43s Gradient pill (purple → green) scales in behind line 2 with glow halo
|
||||
5.13 – 5.63s "frame by frame." types char-by-char inside the pill
|
||||
0 – 6.50s Continuous: video float, cursor blink, stat breath
|
||||
(each gated by time window)
|
||||
so entry, continuous motion, and static tilt never overwrite each other
|
||||
- Conditional rendering ({frame > X && ...}) replaced by permanent DOM +
|
||||
opacity 0 + gates inside the scene-ticker onUpdate
|
||||
- Cursor blink derived from tl.time() via Math.floor(t * 2) % 2
|
||||
- Demo screen is a self-contained CSS scene (.video-scene); to showcase real
|
||||
footage, drop a local mp4 back in as a muted <video class="clip"> (see
|
||||
the note at the .video-content markup)
|
||||
- 3D depth layers built once at composition setup with document.createElement
|
||||
-->
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg-dark: #0a0a18;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: rgba(255, 255, 255, 0.65);
|
||||
--brand-purple: #7c3aed;
|
||||
--brand-purple-soft: #a78bfa;
|
||||
--brand-green: #22c55e;
|
||||
--brand-purple-glow: rgba(124, 58, 237, 1);
|
||||
--brand-green-glow: rgba(34, 197, 94, 1);
|
||||
}
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-dark);
|
||||
font-family:
|
||||
"Inter",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
BACKGROUND + AMBIENT GLOW
|
||||
============================================================ */
|
||||
.bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse at 50% 50%, rgba(40, 30, 80, 0.5), transparent 65%),
|
||||
linear-gradient(135deg, #0a0a18 0%, #160a1f 100%);
|
||||
}
|
||||
.ambient-glow {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 1536px;
|
||||
height: 864px;
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
rgba(147, 51, 234, 0.13) 0%,
|
||||
rgba(34, 197, 94, 0.08) 30%,
|
||||
transparent 70%
|
||||
);
|
||||
filter: blur(80px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
BADGE
|
||||
============================================================ */
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 42px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 12px 32px;
|
||||
font-size: 112px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
text-shadow:
|
||||
0 0 32px rgba(248, 250, 252, 0.12),
|
||||
0 0 72px rgba(24, 217, 232, 0.1);
|
||||
will-change: transform, opacity;
|
||||
/* initial opacity 0 + scale 0.9 via gsap.set */
|
||||
}
|
||||
.badge .accent {
|
||||
color: var(--brand-purple-soft);
|
||||
background: linear-gradient(135deg, var(--brand-purple-soft) 0%, var(--brand-purple) 72%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
filter: drop-shadow(0 0 28px rgba(124, 58, 237, 0.34));
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
VIDEO CARD — three nested wrappers
|
||||
============================================================ */
|
||||
.video-pos {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 1040px;
|
||||
height: 585px; /* 16:9 ratio, intentionally large for the pivot beat */
|
||||
margin-left: -520px; /* so x: W/2 centers it */
|
||||
margin-top: -292.5px;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
.video-float {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
will-change: transform;
|
||||
}
|
||||
.video-tilt {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform-style: preserve-3d;
|
||||
transform: rotateX(5deg) rotateY(15deg);
|
||||
}
|
||||
.video-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow:
|
||||
0 30px 60px rgba(0, 0, 0, 0.5),
|
||||
0 0 80px rgba(147, 51, 234, 0.2),
|
||||
0 0 120px rgba(34, 197, 94, 0.15);
|
||||
position: relative;
|
||||
}
|
||||
.video-scene {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 30% 30%, rgba(255, 200, 100, 0.4), transparent 50%),
|
||||
linear-gradient(135deg, #1a3a5f 0%, #2a4570 50%, #3a2a5a 100%);
|
||||
}
|
||||
.video-scene::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 35%;
|
||||
left: 40%;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #fbbf24, #f97316);
|
||||
opacity: 0.6;
|
||||
filter: blur(8px);
|
||||
}
|
||||
.video-caption {
|
||||
position: absolute;
|
||||
bottom: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 12px 24px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 34px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.video-caption .accent {
|
||||
color: var(--brand-green);
|
||||
}
|
||||
.video-reflection {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 40%;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08) 0%, transparent 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
MP4 STAT — three nested wrappers + depth stack
|
||||
============================================================ */
|
||||
.stat-pos {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
.stat-breath {
|
||||
will-change: transform;
|
||||
}
|
||||
.stat-tilt {
|
||||
transform-style: preserve-3d;
|
||||
transform: rotateX(5deg) rotateY(-15deg);
|
||||
}
|
||||
.depth-stack {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
font-size: 340px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1;
|
||||
}
|
||||
.depth-stack .depth-layer.front {
|
||||
position: relative;
|
||||
color: var(--brand-green);
|
||||
}
|
||||
.depth-stack .depth-layer.back {
|
||||
position: absolute;
|
||||
top: var(--top);
|
||||
left: var(--left);
|
||||
color: rgba(34, 197, 94, var(--alpha));
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
TYPING STAGE — center, fades in during pivot
|
||||
============================================================ */
|
||||
.typing-stage {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
.typing-tilt {
|
||||
perspective: 1200px;
|
||||
transform-style: preserve-3d;
|
||||
transform: rotateY(-15deg) rotateX(5deg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 30px;
|
||||
}
|
||||
.line1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: pre;
|
||||
}
|
||||
.seg {
|
||||
font-size: 124px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
white-space: pre;
|
||||
line-height: 1;
|
||||
}
|
||||
.seg.accent,
|
||||
.seg.accent2 {
|
||||
color: var(--brand-green);
|
||||
}
|
||||
|
||||
.cursor {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 124px;
|
||||
background-color: var(--text-primary);
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
transform: translateY(4px);
|
||||
opacity: 0;
|
||||
will-change: opacity;
|
||||
}
|
||||
.cursor.green {
|
||||
background-color: var(--brand-green);
|
||||
}
|
||||
|
||||
.line2-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px 64px;
|
||||
}
|
||||
.pill-bg {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) scale(0);
|
||||
width: calc(100% + 80px);
|
||||
height: calc(100% + 40px);
|
||||
border-radius: 80px;
|
||||
background: linear-gradient(90deg, var(--brand-purple) 0%, var(--brand-green) 100%);
|
||||
opacity: 0;
|
||||
z-index: -1;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
.pill-glow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: calc(100% + 100px);
|
||||
height: calc(100% + 60px);
|
||||
border-radius: 80px;
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
var(--brand-purple-glow) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
opacity: 0;
|
||||
filter: blur(30px);
|
||||
z-index: -2;
|
||||
will-change: opacity;
|
||||
}
|
||||
.line2-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
will-change: opacity;
|
||||
}
|
||||
.seg.line2 {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
VIGNETTE
|
||||
============================================================ */
|
||||
.vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse at center, transparent 50%, rgba(0, 0, 0, 0.5) 100%);
|
||||
pointer-events: none;
|
||||
z-index: 400;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="root"
|
||||
data-composition-id="main"
|
||||
data-start="0"
|
||||
data-duration="6.5"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
>
|
||||
<div
|
||||
class="stage clip"
|
||||
data-start="0"
|
||||
data-duration="6.5"
|
||||
data-track-index="1"
|
||||
id="scene-stage"
|
||||
>
|
||||
<div class="bg"></div>
|
||||
<div class="ambient-glow"></div>
|
||||
|
||||
<div class="badge" id="badge">Hyper<span class="accent">Frames</span></div>
|
||||
|
||||
<!-- Video card -->
|
||||
<div class="video-pos" id="video-pos" data-layout-allow-overflow>
|
||||
<div class="video-float" id="video-float">
|
||||
<div class="video-tilt">
|
||||
<div class="video-content">
|
||||
<div class="video-scene"></div>
|
||||
<!-- To showcase real footage, add a local mp4 on top of .video-scene:
|
||||
<video id="demo-video" class="clip" src="assets/demo.mp4"
|
||||
style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover"
|
||||
data-start="0.17" data-duration="3.69" data-track-index="2"
|
||||
data-media-start="0" muted playsinline></video> -->
|
||||
<div class="video-caption">
|
||||
HTML, CSS and JS become <span class="accent">video</span>
|
||||
</div>
|
||||
<div class="video-reflection"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MP4 stat -->
|
||||
<div class="stat-pos" id="stat-pos" data-layout-allow-overflow>
|
||||
<div class="stat-breath" id="stat-breath">
|
||||
<div class="stat-tilt">
|
||||
<div class="depth-stack" id="depth-stack-97" data-text="MP4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Typing -->
|
||||
<div class="typing-stage" id="typing-stage">
|
||||
<div class="typing-tilt">
|
||||
<div class="line1">
|
||||
<span class="seg main">HTML </span><span class="seg accent">pages</span
|
||||
><span class="seg suffix"> become </span><span class="seg accent2">video</span
|
||||
><span class="cursor cursor1" id="cursor1"></span>
|
||||
</div>
|
||||
<div class="line2-wrap">
|
||||
<div class="pill-bg" id="pill-bg"></div>
|
||||
<div class="pill-glow" id="pill-glow"></div>
|
||||
<div class="line2-content">
|
||||
<span class="seg line2">frame by frame.</span
|
||||
><span class="cursor cursor2" id="cursor2"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vignette"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ================================================================
|
||||
CONSTANTS
|
||||
================================================================ */
|
||||
const W = 1920,
|
||||
H = 1080;
|
||||
const TOTAL_DUR = 6.5;
|
||||
|
||||
/* ASR-anchored timing (the source source frame counts converted to seconds at 30fps). */
|
||||
const TIMING = {
|
||||
// Phase 1: video + badge enter
|
||||
badgeAt: 0.17,
|
||||
badgeDur: 0.7,
|
||||
videoEntryAt: 0.17,
|
||||
videoEntryDur: 0.7,
|
||||
|
||||
// Phase 2: video slides, MP4 appears
|
||||
slideAt: 2.2,
|
||||
videoSlideDur: 0.8,
|
||||
videoSlideX: W * 0.29,
|
||||
videoSlideScale: 0.92,
|
||||
|
||||
statEntryDur: 0.6,
|
||||
statEntryX: W * 0.6,
|
||||
statBreathAt: 3.0, // entry settles by here, breath begins
|
||||
statBreathDur: 0.86, // until exit at 3.86
|
||||
|
||||
// Phase 3: pivot — both exit, typing begins
|
||||
pivotAt: 3.86,
|
||||
exitDur: 0.6,
|
||||
videoExitX: -W * 0.5,
|
||||
statExitX: -W * 0.7,
|
||||
typingStageDur: 0.5,
|
||||
|
||||
// Phase 3 typing
|
||||
typingStartAt: 4.36, // pivotAt + 0.5 s stage settle
|
||||
typeRate: 30, // chars/sec
|
||||
|
||||
// Phase 4: pill
|
||||
pillAt: 4.83, // typing reaches line2 at this time
|
||||
pillDur: 0.6,
|
||||
pillGlowDur: 0.8,
|
||||
};
|
||||
|
||||
/* Text segments and boundaries */
|
||||
const SEG = {
|
||||
main: "HTML ",
|
||||
accent: "pages",
|
||||
suffix: " become ",
|
||||
accent2: "video",
|
||||
line2: "frame by frame.",
|
||||
};
|
||||
const BOUNDS = {
|
||||
mainEnd: SEG.main.length, // 5
|
||||
accentEnd: SEG.main.length + SEG.accent.length, // 10
|
||||
suffixEnd: SEG.main.length + SEG.accent.length + SEG.suffix.length, // 18
|
||||
accent2End: SEG.main.length + SEG.accent.length + SEG.suffix.length + SEG.accent2.length, // 23
|
||||
line2End:
|
||||
SEG.main.length +
|
||||
SEG.accent.length +
|
||||
SEG.suffix.length +
|
||||
SEG.accent2.length +
|
||||
SEG.line2.length, // 38
|
||||
};
|
||||
const TYPING_DUR = (BOUNDS.line2End - 0) / TIMING.typeRate; // ≈ 1.3 s
|
||||
|
||||
/* ================================================================
|
||||
BUILD DOM — 5-layer depth stack for "MP4"
|
||||
================================================================ */
|
||||
const LAYER_COUNT = 5;
|
||||
const OFFSET_X = 1;
|
||||
const OFFSET_Y = 2;
|
||||
const depthStack = document.getElementById("depth-stack-97");
|
||||
const depthText = depthStack.dataset.text;
|
||||
for (let i = 0; i < LAYER_COUNT; i++) {
|
||||
const layer = document.createElement("div");
|
||||
layer.className = "depth-layer " + (i === LAYER_COUNT - 1 ? "front" : "back");
|
||||
layer.textContent = depthText;
|
||||
if (i < LAYER_COUNT - 1) {
|
||||
layer.setAttribute("aria-hidden", "true");
|
||||
const alpha = 0.62 + 0.08 * (LAYER_COUNT - 2 - i);
|
||||
layer.style.setProperty("--alpha", alpha);
|
||||
layer.style.setProperty("--top", i * OFFSET_Y + "px");
|
||||
layer.style.setProperty("--left", -i * OFFSET_X + "px");
|
||||
}
|
||||
depthStack.appendChild(layer);
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
INITIAL STATES (via gsap.set, before the timeline runs)
|
||||
================================================================ */
|
||||
gsap.set("#badge", { opacity: 0, scale: 0.9 });
|
||||
|
||||
// Video starts centered (x = W/2, y = H/2 via the .video-pos margin trick)
|
||||
gsap.set("#video-pos", { x: W / 2, y: H / 2, scale: 0.6, opacity: 0 });
|
||||
|
||||
// Stat starts off-right + below + small + invisible
|
||||
gsap.set("#stat-pos", { x: TIMING.statEntryX, y: H * 0.42 + 60, scale: 0.3, opacity: 0 });
|
||||
|
||||
gsap.set("#typing-stage", { scale: 0.9, opacity: 0 });
|
||||
|
||||
/* ================================================================
|
||||
TIMELINE
|
||||
================================================================ */
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
window.__timelines["main"] = tl;
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
PHASE 1: Badge + Video entry
|
||||
---------------------------------------------------------------- */
|
||||
tl.to(
|
||||
"#badge",
|
||||
{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
duration: TIMING.badgeDur,
|
||||
ease: "power3.out",
|
||||
},
|
||||
TIMING.badgeAt,
|
||||
);
|
||||
|
||||
tl.to(
|
||||
"#video-pos",
|
||||
{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
duration: TIMING.videoEntryDur,
|
||||
ease: "power3.out", // spring(stiffness:80, damping:15)
|
||||
},
|
||||
TIMING.videoEntryAt,
|
||||
);
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
PHASE 2: Video slides left, MP4 appears on right
|
||||
---------------------------------------------------------------- */
|
||||
tl.to(
|
||||
"#video-pos",
|
||||
{
|
||||
x: TIMING.videoSlideX,
|
||||
scale: TIMING.videoSlideScale,
|
||||
duration: TIMING.videoSlideDur,
|
||||
ease: "power3.out", // spring(stiffness:100, damping:18)
|
||||
},
|
||||
TIMING.slideAt,
|
||||
);
|
||||
|
||||
tl.to(
|
||||
"#stat-pos",
|
||||
{
|
||||
y: H * 0.42, // rises to final y
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
duration: TIMING.statEntryDur,
|
||||
ease: "back.out(1.6)", // spring(stiffness:150, damping:12)
|
||||
},
|
||||
TIMING.slideAt,
|
||||
);
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
PHASE 3: Pivot — video + stat exit; typing fades in
|
||||
---------------------------------------------------------------- */
|
||||
tl.to(
|
||||
"#video-pos",
|
||||
{
|
||||
x: TIMING.videoExitX,
|
||||
scale: 0.8,
|
||||
opacity: 0,
|
||||
duration: TIMING.exitDur,
|
||||
ease: "power3.out", // spring(stiffness:120, damping:20)
|
||||
},
|
||||
TIMING.pivotAt,
|
||||
);
|
||||
|
||||
tl.to(
|
||||
"#stat-pos",
|
||||
{
|
||||
x: TIMING.statExitX,
|
||||
scale: 0.8,
|
||||
opacity: 0,
|
||||
duration: TIMING.exitDur,
|
||||
ease: "power3.out",
|
||||
},
|
||||
TIMING.pivotAt,
|
||||
);
|
||||
|
||||
tl.to(
|
||||
"#typing-stage",
|
||||
{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
duration: TIMING.typingStageDur,
|
||||
ease: "power3.out", // spring(stiffness:100, damping:15)
|
||||
},
|
||||
TIMING.pivotAt,
|
||||
);
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
PHASE 3 (continued): Character-by-character typing
|
||||
---------------------------------------------------------------- */
|
||||
const segMain = document.querySelector(".seg.main");
|
||||
const segAccent = document.querySelector(".seg.accent");
|
||||
const segSuffix = document.querySelector(".seg.suffix");
|
||||
const segAccent2 = document.querySelector(".seg.accent2");
|
||||
const segLine2 = document.querySelector(".seg.line2");
|
||||
|
||||
const typeProxy = { idx: 0 };
|
||||
function typedIndexAt(t) {
|
||||
if (t < TIMING.typingStartAt) return 0;
|
||||
return Math.min(BOUNDS.line2End, Math.floor((t - TIMING.typingStartAt) * TIMING.typeRate));
|
||||
}
|
||||
|
||||
function renderTypedText(i) {
|
||||
const m = SEG.main.slice(0, Math.min(i, BOUNDS.mainEnd));
|
||||
const a = SEG.accent.slice(0, Math.max(0, Math.min(i - BOUNDS.mainEnd, SEG.accent.length)));
|
||||
const s = SEG.suffix.slice(
|
||||
0,
|
||||
Math.max(0, Math.min(i - BOUNDS.accentEnd, SEG.suffix.length)),
|
||||
);
|
||||
const a2 = SEG.accent2.slice(
|
||||
0,
|
||||
Math.max(0, Math.min(i - BOUNDS.suffixEnd, SEG.accent2.length)),
|
||||
);
|
||||
const l2 = SEG.line2.slice(
|
||||
0,
|
||||
Math.max(0, Math.min(i - BOUNDS.accent2End, SEG.line2.length)),
|
||||
);
|
||||
if (segMain.textContent !== m) segMain.textContent = m;
|
||||
if (segAccent.textContent !== a) segAccent.textContent = a;
|
||||
if (segSuffix.textContent !== s) segSuffix.textContent = s;
|
||||
if (segAccent2.textContent !== a2) segAccent2.textContent = a2;
|
||||
if (segLine2.textContent !== l2) segLine2.textContent = l2;
|
||||
}
|
||||
|
||||
tl.to(
|
||||
typeProxy,
|
||||
{
|
||||
idx: BOUNDS.line2End,
|
||||
duration: TYPING_DUR,
|
||||
ease: "none",
|
||||
onUpdate: function () {
|
||||
renderTypedText(Math.floor(typeProxy.idx));
|
||||
},
|
||||
},
|
||||
TIMING.typingStartAt,
|
||||
);
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
PHASE 4: Gradient pill behind line 2
|
||||
---------------------------------------------------------------- */
|
||||
gsap.set("#pill-bg", { scaleX: 0, scaleY: 0.5, opacity: 0 });
|
||||
gsap.set("#pill-glow", { opacity: 0 });
|
||||
|
||||
tl.to(
|
||||
"#pill-bg",
|
||||
{
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
opacity: 0.9,
|
||||
duration: TIMING.pillDur,
|
||||
ease: "power3.out", // spring(stiffness:80, damping:15)
|
||||
},
|
||||
TIMING.pillAt,
|
||||
);
|
||||
|
||||
tl.to(
|
||||
"#pill-glow",
|
||||
{
|
||||
opacity: 0.5,
|
||||
duration: TIMING.pillGlowDur,
|
||||
ease: "power2.out",
|
||||
},
|
||||
TIMING.pillAt,
|
||||
);
|
||||
|
||||
tl.to(
|
||||
".line2-content",
|
||||
{
|
||||
opacity: 1,
|
||||
duration: 0.18,
|
||||
ease: "power2.out",
|
||||
},
|
||||
TIMING.pillAt + 0.3,
|
||||
);
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
CONTINUOUS SCENE-TICKER — float, breath, cursor blink
|
||||
All gated by time windows so they only fire when visible.
|
||||
---------------------------------------------------------------- */
|
||||
const videoFloat = document.getElementById("video-float");
|
||||
const statBreathEl = document.getElementById("stat-breath");
|
||||
const cursor1El = document.getElementById("cursor1");
|
||||
const cursor2El = document.getElementById("cursor2");
|
||||
|
||||
tl.to(
|
||||
{ tick: 0 },
|
||||
{
|
||||
tick: 1,
|
||||
duration: TOTAL_DUR,
|
||||
ease: "none",
|
||||
onUpdate: function () {
|
||||
const t = tl.time();
|
||||
const idx = typedIndexAt(t);
|
||||
renderTypedText(idx);
|
||||
|
||||
// Video float — only while video is visible (Phase 1 + 2)
|
||||
if (t < TIMING.pivotAt) {
|
||||
const floatY = Math.sin(t * 0.9) * 6;
|
||||
gsap.set(videoFloat, { y: floatY });
|
||||
}
|
||||
|
||||
// Stat breath — only while MP4 is settled and not yet exiting
|
||||
if (t > TIMING.statBreathAt && t < TIMING.pivotAt) {
|
||||
const breath = 1 + Math.sin((t - TIMING.statBreathAt) * 1.2) * 0.02;
|
||||
gsap.set(statBreathEl, { scale: breath });
|
||||
}
|
||||
|
||||
// Cursor blink — 1 Hz (Math.floor(t * 2) % 2 === 0 → on for 0.5s, off for 0.5s)
|
||||
const blink = Math.floor(t * 2) % 2 === 0 ? 1 : 0;
|
||||
|
||||
// Cursor 1: visible while typing line 1 (idx < accent2End)
|
||||
// Determine cursor color: green when typing accent segments, white otherwise.
|
||||
if (t >= TIMING.typingStartAt && idx < BOUNDS.accent2End) {
|
||||
const inAccent = idx >= BOUNDS.mainEnd && idx < BOUNDS.accentEnd;
|
||||
const inAccent2 = idx >= BOUNDS.suffixEnd && idx < BOUNDS.accent2End;
|
||||
const isGreen = inAccent || inAccent2;
|
||||
cursor1El.style.opacity = blink;
|
||||
if (cursor1El._lastGreen !== isGreen) {
|
||||
cursor1El.classList.toggle("green", isGreen);
|
||||
cursor1El._lastGreen = isGreen;
|
||||
}
|
||||
} else {
|
||||
cursor1El.style.opacity = 0;
|
||||
}
|
||||
|
||||
// Cursor 2: visible while typing line 2 (idx ≥ accent2End && idx < line2End)
|
||||
if (idx >= BOUNDS.accent2End && idx < BOUNDS.line2End) {
|
||||
cursor2El.style.opacity = blink;
|
||||
} else {
|
||||
cursor2El.style.opacity = 0;
|
||||
}
|
||||
},
|
||||
},
|
||||
0,
|
||||
);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,861 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=1920, height=1080" />
|
||||
<title>Scene 03 — HyperFrames Anchor Chain Reveal</title>
|
||||
|
||||
<!--
|
||||
HyperFrames-native version of the scene-03-anchor-chain-reveal concept.
|
||||
Same 5-phase choreography, GSAP-driven, seek-safe.
|
||||
|
||||
Audio (assumed accompanying narration):
|
||||
"Meet HyperFrames, the HTML video composition toolkit for building,
|
||||
previewing, and rendering programmable motion."
|
||||
|
||||
Sub-shots (local time, 8s total):
|
||||
0.0 – 1.2s "HyperFrames" Brand reveal (hacker-flip)
|
||||
1.2 – 4.0s "HTML VIDEO" Text swap + rolling ticker
|
||||
4.0 – 6.3s "60FPS" Counter + capability cloud
|
||||
6.3 – 8.0s "Built for Motion" Capability strip
|
||||
|
||||
Placeholders (self-contained — no asset files needed):
|
||||
inline-SVG "HF" mark — brand logo (swap for your image)
|
||||
CSS initials circles — user avatars (edit CREATOR_INITIALS)
|
||||
CSS text chips — partner brand logos (edit --brand-name)
|
||||
Replace with real assets for production.
|
||||
-->
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
/* Palette — direct port of the the source COLORS constants */
|
||||
--text-primary: #ffffff;
|
||||
--text-muted: rgba(255, 255, 255, 0.45);
|
||||
--brand-green: #00e676;
|
||||
--bg-grad-from: #0a1a2e;
|
||||
--bg-grad-to: #1a0a2e;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
font-family:
|
||||
"Inter",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
BACKGROUND (port of <FrostedGlassBackground />)
|
||||
============================================================ */
|
||||
.bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse at 30% 20%, rgba(0, 230, 118, 0.18), transparent 50%),
|
||||
radial-gradient(ellipse at 70% 80%, rgba(120, 60, 220, 0.18), transparent 50%),
|
||||
linear-gradient(135deg, var(--bg-grad-from), var(--bg-grad-to));
|
||||
}
|
||||
|
||||
/* Camera wrapper — port of <CameraSystem /> */
|
||||
.camera {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
PHASES 1–3 (BrandReveal): logo + decode + swap + recenter
|
||||
============================================================ */
|
||||
.brand-stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50; /* above cloud SVG (z:1), below logo (z:100) */
|
||||
}
|
||||
.anchor-shift {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 35px;
|
||||
/* GSAP tweens .x on this element for Phase-2 recenter */
|
||||
}
|
||||
.anchor-logo {
|
||||
position: relative;
|
||||
width: 192px;
|
||||
height: 192px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100; /* highest — sits above lines + avatars */
|
||||
opacity: 1;
|
||||
/* initial scale(0) set by GSAP fromTo — no CSS transform here */
|
||||
}
|
||||
.anchor-logo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 4px 24px rgba(0, 0, 0, 0.4));
|
||||
}
|
||||
.anchor-text {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.phase1-text {
|
||||
display: flex;
|
||||
perspective: 800px; /* required for the per-glyph rotateX */
|
||||
white-space: nowrap;
|
||||
}
|
||||
.flip-glyph {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
font-size: 163px; /* 1920 × 0.085 — matches the source brandFontSize */
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.flip-glyph .ghost {
|
||||
opacity: 0;
|
||||
}
|
||||
.flip-glyph .anim {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
color: var(--text-primary);
|
||||
opacity: 0;
|
||||
transform: perspective(600px) rotateX(90deg);
|
||||
transform-origin: bottom;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
.flip-glyph.space {
|
||||
min-width: 0.35em;
|
||||
}
|
||||
|
||||
.phase2-claim {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
opacity: 0;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 40px;
|
||||
}
|
||||
.claim-rank {
|
||||
color: var(--brand-green);
|
||||
font-size: 163px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.claim-ai,
|
||||
.claim-video {
|
||||
color: var(--text-primary);
|
||||
font-size: 163px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* Vertical ticker — port of <RollingWord /> */
|
||||
.ticker-window {
|
||||
height: 204px; /* fontSize × 1.25 = 163 × 1.25 ≈ 204 */
|
||||
overflow: hidden;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
.ticker-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
will-change: transform;
|
||||
}
|
||||
.ticker-item {
|
||||
height: 204px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-primary);
|
||||
font-weight: 900;
|
||||
font-size: 163px;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
PHASE 4 (CreatorCloud): counter + avatar cloud + lines
|
||||
============================================================ */
|
||||
.cloud-stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 10; /* below brand-stage's logo (z:100), below counter */
|
||||
opacity: 0; /* GSAP fades it in */
|
||||
}
|
||||
.counter-pin {
|
||||
position: absolute;
|
||||
top: 3%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
text-align: center;
|
||||
z-index: 60; /* above brand-stage so it overlays cleanly */
|
||||
}
|
||||
.count {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 900;
|
||||
font-size: 115px; /* 1920 × 0.06 */
|
||||
color: var(--brand-green);
|
||||
display: inline-block;
|
||||
min-width: 200px;
|
||||
text-align: right;
|
||||
}
|
||||
.count-suffix {
|
||||
font-weight: 900;
|
||||
font-size: 115px;
|
||||
color: var(--brand-green);
|
||||
opacity: 0; /* GSAP fades in after count */
|
||||
}
|
||||
.cloud-lines {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
.cloud-avatars {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.cloud-avatar {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
border: 3px solid rgba(255, 255, 255, 0.2);
|
||||
opacity: 0;
|
||||
/* initial scale(0) set by GSAP fromTo — no CSS transform here */
|
||||
background: linear-gradient(135deg, #6b8cff, #a6b3ff);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-weight: 700;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
PHASE 5 (BrandShowcase): label + scrolling brand strip
|
||||
============================================================ */
|
||||
.brand-strip {
|
||||
position: absolute;
|
||||
bottom: 8%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
opacity: 0; /* GSAP fades + translateY in via fromTo */
|
||||
z-index: 70;
|
||||
}
|
||||
.brand-label {
|
||||
text-align: center;
|
||||
margin-bottom: 25px;
|
||||
font-size: 27px; /* 1920 × 0.014 */
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.brand-strip-window {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 80px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.brand-strip-track {
|
||||
display: flex;
|
||||
gap: 110px;
|
||||
}
|
||||
.brand-logo {
|
||||
width: 170px;
|
||||
height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
/* initial scale(0.5) set by GSAP fromTo — no CSS transform here */
|
||||
}
|
||||
.brand-logo::before {
|
||||
content: var(--brand-name);
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
white-space: nowrap;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
.brand-nvidia {
|
||||
--brand-name: "NVIDIA";
|
||||
}
|
||||
.brand-visa {
|
||||
--brand-name: "VISA";
|
||||
}
|
||||
.brand-zoominfo {
|
||||
--brand-name: "ZOOMINFO";
|
||||
}
|
||||
.brand-github {
|
||||
--brand-name: "GITHUB";
|
||||
}
|
||||
|
||||
/* Vignette overlay */
|
||||
.vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse at center, transparent 40%, rgba(0, 0, 0, 0.45) 100%);
|
||||
pointer-events: none;
|
||||
z-index: 200;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="root"
|
||||
data-composition-id="main"
|
||||
data-start="0"
|
||||
data-duration="8"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
>
|
||||
<div class="bg"></div>
|
||||
|
||||
<div class="camera">
|
||||
<!-- =====================================================
|
||||
PHASES 1–3: BrandReveal (logo + hacker-flip + swap)
|
||||
===================================================== -->
|
||||
<div
|
||||
id="phase-anchor"
|
||||
class="brand-stage clip"
|
||||
data-start="0"
|
||||
data-duration="8"
|
||||
data-track-index="1"
|
||||
>
|
||||
<div class="anchor-shift" data-layout-allow-overflow>
|
||||
<!-- Anchor logo — persists across all phases -->
|
||||
<div class="anchor-logo">
|
||||
<!-- Inline-SVG placeholder mark — swap for your logo image -->
|
||||
<svg
|
||||
class="logo-mark"
|
||||
viewBox="0 0 100 100"
|
||||
width="100%"
|
||||
height="100%"
|
||||
role="img"
|
||||
aria-label="hyperframes logo"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="hfMark" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#8b7bff" />
|
||||
<stop offset="1" stop-color="#3ddc97" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="4" y="4" width="92" height="92" rx="22" fill="url(#hfMark)" />
|
||||
<text
|
||||
x="50"
|
||||
y="63"
|
||||
text-anchor="middle"
|
||||
font-family="Inter, system-ui, sans-serif"
|
||||
font-size="40"
|
||||
font-weight="800"
|
||||
fill="#fff"
|
||||
>
|
||||
HF
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Swappable text zone -->
|
||||
<div class="anchor-text">
|
||||
<!-- Phase 1: 'HyperFrames' decoded character by character -->
|
||||
<div class="phase1-text" aria-label="HyperFrames">
|
||||
<!-- Spans generated by the script below -->
|
||||
</div>
|
||||
|
||||
<!-- Phase 2: 'HTML video render/ship' claim -->
|
||||
<div class="phase2-claim">
|
||||
<span class="claim-rank">HTML</span>
|
||||
<span class="claim-ai">Video</span>
|
||||
<div class="claim-ticker">
|
||||
<div class="ticker-window" data-layout-allow-overflow>
|
||||
<div class="ticker-stack">
|
||||
<div class="ticker-item">render</div>
|
||||
<div class="ticker-item">ship</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- =====================================================
|
||||
PHASE 4: CreatorCloud (counter + avatar ring + lines)
|
||||
===================================================== -->
|
||||
<div
|
||||
id="phase-cloud"
|
||||
class="cloud-stage clip"
|
||||
data-start="3.6"
|
||||
data-duration="4.4"
|
||||
data-track-index="2"
|
||||
>
|
||||
<div class="counter-pin">
|
||||
<span class="count">60</span><span class="count-suffix">FPS</span>
|
||||
</div>
|
||||
<svg class="cloud-lines" width="1920" height="1080">
|
||||
<defs>
|
||||
<linearGradient id="lineGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#00e676" stop-opacity="0.8" />
|
||||
<stop offset="100%" stop-color="#00e676" stop-opacity="0.2" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- <line> elements generated by JS -->
|
||||
</svg>
|
||||
<div class="cloud-avatars">
|
||||
<!-- .cloud-avatar elements generated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- =====================================================
|
||||
PHASE 5: BrandShowcase (label + scrolling logos)
|
||||
===================================================== -->
|
||||
<div
|
||||
id="phase-brands"
|
||||
class="brand-strip clip"
|
||||
data-start="6.3"
|
||||
data-duration="1.7"
|
||||
data-track-index="3"
|
||||
>
|
||||
<div class="brand-label">Trusted by Leading Brands</div>
|
||||
<div class="brand-strip-window" data-layout-allow-overflow>
|
||||
<div class="brand-strip-track">
|
||||
<div class="brand-logo brand-nvidia" aria-label="NVIDIA"></div>
|
||||
<div class="brand-logo brand-visa" aria-label="Visa"></div>
|
||||
<div class="brand-logo brand-zoominfo" aria-label="ZoomInfo"></div>
|
||||
<div class="brand-logo brand-github" aria-label="GitHub"></div>
|
||||
<div class="brand-logo brand-nvidia" aria-label="NVIDIA"></div>
|
||||
<div class="brand-logo brand-visa" aria-label="Visa"></div>
|
||||
<div class="brand-logo brand-zoominfo" aria-label="ZoomInfo"></div>
|
||||
<div class="brand-logo brand-github" aria-label="GitHub"></div>
|
||||
<div class="brand-logo brand-nvidia" aria-label="NVIDIA"></div>
|
||||
<div class="brand-logo brand-visa" aria-label="Visa"></div>
|
||||
<div class="brand-logo brand-zoominfo" aria-label="ZoomInfo"></div>
|
||||
<div class="brand-logo brand-github" aria-label="GitHub"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /.camera -->
|
||||
|
||||
<div class="vignette"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ================================================================
|
||||
CONSTANTS — all derived once, never recomputed at tween time.
|
||||
Composition is 1920 × 1080, total duration 8.0s.
|
||||
================================================================ */
|
||||
const W = 1920,
|
||||
H = 1080;
|
||||
const FPS_HASH = 60; // synthetic clock for the hacker-flip flicker hash only
|
||||
|
||||
// Self-contained placeholder data — swap for real asset paths in production.
|
||||
const CREATOR_INITIALS = ["JC", "MK", "AR", "TS", "LP", "DV", "NW", "SB", "KH", "EM"];
|
||||
|
||||
const TIMING = {
|
||||
// Phase 1 — logo pop + HyperFrames decode
|
||||
logoPop: 0.32,
|
||||
flipStart: 0.4, // logoPop + ~5/60
|
||||
flipStagger: 0.033, // 2 frames at 60fps per glyph
|
||||
flipDuration: 0.55,
|
||||
|
||||
// Phase 2 — slide-out + claim slide-in.
|
||||
swapTrigger: 1.38,
|
||||
swapDuration: 0.55,
|
||||
claimFadeIn: 1.65, // swapTrigger + ~8/60
|
||||
claimFadeDur: 0.27,
|
||||
|
||||
// Rolling ticker — render → ship.
|
||||
tickerTrigger: 2.35,
|
||||
tickerDuration: 0.45,
|
||||
|
||||
// Phase 3 — logo recenters (anchored ~0.25s before "trusted")
|
||||
recenterTrigger: 3.87,
|
||||
recenterDuration: 0.9,
|
||||
|
||||
// Phase 4 — cloud + counter.
|
||||
cloudFadeIn: 4.12,
|
||||
cloudFadeDur: 0.4,
|
||||
avatarEntryStart: 4.3,
|
||||
avatarStagger: 0.1,
|
||||
avatarEntryDur: 0.55,
|
||||
linesDelay: 0.2, // gap after last avatar
|
||||
linesDur: 0.45,
|
||||
lineStagger: 0.033,
|
||||
|
||||
counterStart: 4.92,
|
||||
counterDuration: 0.85,
|
||||
suffixDelay: 0.05,
|
||||
|
||||
// Phase 5 — brand logos
|
||||
brandStripStart: 6.5,
|
||||
brandStripFadeDur: 0.35,
|
||||
brandLogoStagger: 0.1,
|
||||
brandScrollDur: 1.4, // until end (8.0 - 6.5 - 0.1 buffer)
|
||||
};
|
||||
|
||||
// Layout constants — shared center keeps logo, avatars, and lines aligned.
|
||||
const RECENTER_OFFSET = -W * 0.12; // = -230.4 px — container shift in Phase 2
|
||||
const CENTER_OFFSET = 800; // Phase 3: logo center aligns with cloud center.
|
||||
|
||||
// Cloud geometry — logo final position and line origins derive from this center.
|
||||
const CLOUD_CENTER_X = W / 2;
|
||||
const CLOUD_CENTER_Y = H * 0.47; // = 507.6 px
|
||||
const VERTICAL_ADJUST = CLOUD_CENTER_Y - H / 2;
|
||||
const CLOUD_RADIUS_X = W * 0.25; // = 480 px
|
||||
const CLOUD_RADIUS_Y = H * 0.22; // = 237.6 px
|
||||
const AVATAR_COUNT = 10;
|
||||
|
||||
/* ================================================================
|
||||
BUILD DOM — flip glyphs, avatars, connection lines.
|
||||
Runs synchronously before the timeline registration.
|
||||
================================================================ */
|
||||
|
||||
// ---- Hacker-flip glyphs for "HyperFrames" -------------------------
|
||||
const phase1 = document.querySelector(".phase1-text");
|
||||
const decodeText = "HyperFrames";
|
||||
decodeText.split("").forEach((char, index) => {
|
||||
const span = document.createElement("span");
|
||||
span.className = "flip-glyph" + (char === " " ? " space" : "");
|
||||
span.dataset.char = char;
|
||||
span.dataset.index = String(index);
|
||||
const ghost = document.createElement("span");
|
||||
ghost.className = "ghost";
|
||||
ghost.textContent = char === " " ? " " : char;
|
||||
const anim = document.createElement("span");
|
||||
anim.className = "anim";
|
||||
anim.textContent = char === " " ? " " : char;
|
||||
span.append(ghost, anim);
|
||||
phase1.appendChild(span);
|
||||
});
|
||||
|
||||
// ---- Avatar cloud + connection lines ------------------------------
|
||||
const cloudLines = document.querySelector(".cloud-lines");
|
||||
const cloudAvatars = document.querySelector(".cloud-avatars");
|
||||
const avatarPositions = [];
|
||||
const avatars = CREATOR_INITIALS.slice(0, AVATAR_COUNT);
|
||||
|
||||
for (let i = 0; i < avatars.length; i++) {
|
||||
const angle = (i / avatars.length) * Math.PI * 2 - Math.PI / 2;
|
||||
const x = CLOUD_CENTER_X + Math.cos(angle) * CLOUD_RADIUS_X;
|
||||
const y = CLOUD_CENTER_Y + Math.sin(angle) * CLOUD_RADIUS_Y;
|
||||
avatarPositions.push({ x, y });
|
||||
|
||||
const size = 90 + (i % 3) * 15;
|
||||
const av = document.createElement("div");
|
||||
av.className = "cloud-avatar";
|
||||
av.style.left = x - size / 2 + "px";
|
||||
av.style.top = y - size / 2 + "px";
|
||||
av.style.width = size + "px";
|
||||
av.style.height = size + "px";
|
||||
av.dataset.index = String(i);
|
||||
// Initials placeholder — deterministic hue from index (no asset files).
|
||||
av.textContent = avatars[i];
|
||||
av.style.background = `linear-gradient(135deg, hsl(${(220 + i * 36) % 360}, 70%, 62%), hsl(${(260 + i * 36) % 360}, 70%, 46%))`;
|
||||
cloudAvatars.appendChild(av);
|
||||
|
||||
// SVG line — both endpoints start at center; will tween x2/y2 outward.
|
||||
const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
|
||||
line.setAttribute("x1", CLOUD_CENTER_X);
|
||||
line.setAttribute("y1", CLOUD_CENTER_Y);
|
||||
line.setAttribute("x2", CLOUD_CENTER_X);
|
||||
line.setAttribute("y2", CLOUD_CENTER_Y);
|
||||
line.setAttribute("stroke", "#00e676");
|
||||
line.setAttribute("stroke-width", "2");
|
||||
line.setAttribute("stroke-dasharray", "4 4");
|
||||
line.setAttribute("stroke-opacity", "0");
|
||||
cloudLines.appendChild(line);
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
TIMELINE
|
||||
================================================================ */
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Camera: gentle initial zoom + drift. Finite, computed from duration.
|
||||
// ----------------------------------------------------------------
|
||||
tl.fromTo(".camera", { scale: 0.95 }, { scale: 1.0, duration: 0.95, ease: "power2.out" }, 0);
|
||||
|
||||
// Subtle drift across the whole 8s. Finite yoyo cycles, not infinite.
|
||||
// sin(frame * 0.003) cycle = 2π / 0.003 frames = 2094 frames ≈ 34.9s at 60fps.
|
||||
// Over 8s we see roughly a quarter cycle — model as a single slow yoyo.
|
||||
tl.fromTo(
|
||||
".camera",
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 3, y: 2, duration: 4, ease: "sine.inOut", yoyo: true, repeat: 1 },
|
||||
0,
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// PHASE 1: Logo pop + HyperFrames decode
|
||||
// ----------------------------------------------------------------
|
||||
tl.fromTo(
|
||||
".anchor-logo",
|
||||
{ scale: 0 },
|
||||
{
|
||||
scale: 1,
|
||||
duration: 0.6,
|
||||
ease: "back.out(1.4)", // spring(stiffness:180, damping:12)
|
||||
},
|
||||
TIMING.logoPop,
|
||||
);
|
||||
|
||||
// Hacker-flip per glyph. Each glyph has its own tween with onUpdate
|
||||
// that derives the visible character from time.
|
||||
const CHAR_POOL = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@#$%&";
|
||||
const FLICKER = 3; // frames between glyph reshuffles
|
||||
const REVEAL_AT = 0.6; // progress threshold to swap random → real
|
||||
|
||||
function pseudoHash(i, t) {
|
||||
// Cheap deterministic 32-bit mix — no Math.random, no Date.now
|
||||
return ((i * 374761393 + t * 668265263) >>> 0) % CHAR_POOL.length;
|
||||
}
|
||||
|
||||
document.querySelectorAll(".flip-glyph").forEach((glyph) => {
|
||||
const index = Number(glyph.dataset.index);
|
||||
const real = glyph.dataset.char === " " ? " " : glyph.dataset.char;
|
||||
const anim = glyph.querySelector(".anim");
|
||||
const start = TIMING.flipStart + index * TIMING.flipStagger;
|
||||
|
||||
tl.fromTo(
|
||||
anim,
|
||||
{ rotationX: 90, opacity: 0, "--p": 0 },
|
||||
{
|
||||
rotationX: 0,
|
||||
opacity: 1,
|
||||
"--p": 1,
|
||||
duration: TIMING.flipDuration,
|
||||
ease: "back.out(1.6)", // spring(stiffness:150, damping:14)
|
||||
onUpdate: function () {
|
||||
const p = Number(gsap.getProperty(anim, "--p"));
|
||||
if (p >= REVEAL_AT) {
|
||||
if (anim.textContent !== real) anim.textContent = real;
|
||||
} else {
|
||||
const localFrame = Math.floor((tl.time() - start) * FPS_HASH);
|
||||
const bucket = Math.max(0, Math.floor(localFrame / FLICKER));
|
||||
anim.textContent = CHAR_POOL[pseudoHash(index, bucket)];
|
||||
}
|
||||
},
|
||||
},
|
||||
start,
|
||||
);
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// PHASE 2: Swap — old text slides out, container shifts left,
|
||||
// claim fades in at the old text's origin.
|
||||
// ----------------------------------------------------------------
|
||||
tl.to(
|
||||
".phase1-text",
|
||||
{
|
||||
x: 200,
|
||||
opacity: 0,
|
||||
duration: TIMING.swapDuration * 0.5,
|
||||
ease: "power3.out",
|
||||
},
|
||||
TIMING.swapTrigger,
|
||||
);
|
||||
tl.set(".phase1-text .anim", { opacity: 0 }, TIMING.swapTrigger + TIMING.swapDuration * 0.5);
|
||||
|
||||
tl.to(
|
||||
".anchor-shift",
|
||||
{
|
||||
x: RECENTER_OFFSET,
|
||||
duration: TIMING.swapDuration,
|
||||
ease: "power3.out", // spring(stiffness:80, damping:18)
|
||||
},
|
||||
TIMING.swapTrigger,
|
||||
);
|
||||
|
||||
tl.to(
|
||||
".phase2-claim",
|
||||
{
|
||||
opacity: 1,
|
||||
duration: TIMING.claimFadeDur,
|
||||
ease: "power2.out",
|
||||
},
|
||||
TIMING.claimFadeIn,
|
||||
);
|
||||
|
||||
// Rolling ticker — render → ship.
|
||||
tl.to(
|
||||
".ticker-stack",
|
||||
{
|
||||
y: "-=204", // one itemHeight
|
||||
duration: TIMING.tickerDuration,
|
||||
ease: "back.out(1.4)", // spring(stiffness:120, damping:14)
|
||||
},
|
||||
TIMING.tickerTrigger,
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// PHASE 3: Logo recenters + text fades out.
|
||||
// ----------------------------------------------------------------
|
||||
tl.to(
|
||||
".anchor-text",
|
||||
{
|
||||
opacity: 0,
|
||||
duration: 0.3,
|
||||
ease: "power2.out",
|
||||
},
|
||||
TIMING.recenterTrigger,
|
||||
);
|
||||
|
||||
tl.to(
|
||||
".anchor-logo",
|
||||
{
|
||||
x: CENTER_OFFSET,
|
||||
y: VERTICAL_ADJUST,
|
||||
duration: TIMING.recenterDuration,
|
||||
ease: "power2.out", // spring(stiffness:45, damping:22) — gentle
|
||||
},
|
||||
TIMING.recenterTrigger,
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// PHASE 4: Avatar cloud + counter.
|
||||
// ----------------------------------------------------------------
|
||||
tl.to(
|
||||
".cloud-stage",
|
||||
{
|
||||
opacity: 1,
|
||||
duration: TIMING.cloudFadeDur,
|
||||
ease: "power2.out",
|
||||
},
|
||||
TIMING.cloudFadeIn,
|
||||
);
|
||||
|
||||
// Avatars cascade in.
|
||||
tl.fromTo(
|
||||
".cloud-avatar",
|
||||
{ scale: 0, opacity: 0 },
|
||||
{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
duration: TIMING.avatarEntryDur,
|
||||
ease: "back.out(1.7)",
|
||||
stagger: { each: TIMING.avatarStagger, from: "start" },
|
||||
},
|
||||
TIMING.avatarEntryStart,
|
||||
);
|
||||
|
||||
// Connection lines draw outward from center after all avatars settle.
|
||||
const lastAvatarEnd =
|
||||
TIMING.avatarEntryStart + TIMING.avatarStagger * (AVATAR_COUNT - 1) + TIMING.avatarEntryDur;
|
||||
const linesStart = lastAvatarEnd + TIMING.linesDelay;
|
||||
|
||||
document.querySelectorAll(".cloud-lines line").forEach((line, i) => {
|
||||
const { x, y } = avatarPositions[i];
|
||||
tl.to(
|
||||
line,
|
||||
{
|
||||
attr: { x2: x, y2: y },
|
||||
strokeOpacity: 0.6,
|
||||
duration: TIMING.linesDur,
|
||||
ease: "power2.out",
|
||||
},
|
||||
linesStart + i * TIMING.lineStagger,
|
||||
);
|
||||
});
|
||||
|
||||
const countEl = document.querySelector(".count");
|
||||
const suffixEl = document.querySelector(".count-suffix");
|
||||
|
||||
// Counter pulse — text stays deterministic under timeline seeking.
|
||||
tl.fromTo(
|
||||
countEl,
|
||||
{
|
||||
scale: 0.88,
|
||||
},
|
||||
{
|
||||
scale: 1,
|
||||
duration: TIMING.counterDuration,
|
||||
ease: "power2.out",
|
||||
},
|
||||
TIMING.counterStart,
|
||||
);
|
||||
|
||||
tl.to(
|
||||
suffixEl,
|
||||
{
|
||||
opacity: 1,
|
||||
duration: 0.3,
|
||||
ease: "back.out(1.6)",
|
||||
},
|
||||
TIMING.counterStart + TIMING.counterDuration + TIMING.suffixDelay,
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// PHASE 5: Brand strip — label + scrolling partner logos.
|
||||
// ----------------------------------------------------------------
|
||||
tl.fromTo(
|
||||
".brand-strip",
|
||||
{ opacity: 0, y: 40 },
|
||||
{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
duration: TIMING.brandStripFadeDur,
|
||||
ease: "power2.out",
|
||||
},
|
||||
TIMING.brandStripStart,
|
||||
);
|
||||
|
||||
tl.fromTo(
|
||||
".brand-logo",
|
||||
{ scale: 0.5, opacity: 0 },
|
||||
{
|
||||
scale: 1,
|
||||
opacity: 0.7,
|
||||
duration: 0.5,
|
||||
ease: "back.out(1.4)",
|
||||
stagger: { each: TIMING.brandLogoStagger, from: "start" },
|
||||
},
|
||||
TIMING.brandStripStart,
|
||||
);
|
||||
|
||||
// Finite horizontal scroll. Distance derived from remaining time × speed.
|
||||
// Speed in original: 0.8 px/frame at 30fps = 24 px/s. Over 1.4s ≈ 33.6 px.
|
||||
// For a more visible scroll, multiply by 4 → ~135 px.
|
||||
tl.to(
|
||||
".brand-strip-track",
|
||||
{
|
||||
x: -135,
|
||||
duration: TIMING.brandScrollDur,
|
||||
ease: "none",
|
||||
},
|
||||
TIMING.brandStripStart,
|
||||
);
|
||||
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,347 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=1920, height=1080" />
|
||||
<title>Scene 5 — HyperFrames Displace Reveal</title>
|
||||
|
||||
<!--
|
||||
HyperFrames composition.tsx.
|
||||
|
||||
Choreography (4 phases, 7.5 seconds total):
|
||||
0.00 – 0.67s Typewriter reveals "HyperFrames turns" (3 → 17 chars)
|
||||
1.67 – 2.22s Ticker scrolls: HTML → motion
|
||||
3.33 – 3.88s Ticker scrolls: motion → video
|
||||
4.60 – 5.45s Logo enters from offscreen-right with rotation + scale impact
|
||||
Text group pushed left + fades (40-50% of hero duration)
|
||||
6.60 – 7.50s Logo breathes (dual-frequency sine onUpdate, multiplicative on 1.3 scale)
|
||||
tuned to 40-50% of hero duration so the impact reads as causal
|
||||
- Breathing uses Form 2 (onUpdate) so it multiplies onto the 1.3 final scale,
|
||||
not overwrites it (Form 1 yoyo would undo the impact)
|
||||
- Two breathing periods (1.0s scale, 1.33s rotation) for organic feel
|
||||
- Logo uses a local static image asset referenced with a plain URL
|
||||
-->
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg: linear-gradient(
|
||||
135deg,
|
||||
#3a3a3a 0%,
|
||||
#17211f 30%,
|
||||
#0b2328 48%,
|
||||
#1f3518 70%,
|
||||
#343434 100%
|
||||
);
|
||||
--text: #f8fafc;
|
||||
--accent: #18d9e8;
|
||||
--accent-2: #7bea5a;
|
||||
}
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
font-family:
|
||||
"Inter",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Text group — typewriter + ticker side by side, displaced as a unit */
|
||||
.text-group {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.typewriter {
|
||||
height: 168px; /* = ITEM_HEIGHT (FONT_SIZE × 1.2) */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text);
|
||||
font-weight: 400;
|
||||
font-size: 140px;
|
||||
line-height: 1;
|
||||
white-space: pre;
|
||||
text-shadow: 0 0 36px rgba(24, 217, 232, 0.18);
|
||||
}
|
||||
|
||||
/* Vertical ticker */
|
||||
.ticker-window {
|
||||
height: 168px;
|
||||
overflow: hidden;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.ticker-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
will-change: transform;
|
||||
}
|
||||
.ticker-item {
|
||||
height: 168px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--accent);
|
||||
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-2) 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
font-weight: 700;
|
||||
font-size: 140px;
|
||||
padding-left: 20px;
|
||||
line-height: 1;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* Hero logo — enters from offscreen right */
|
||||
.hero {
|
||||
position: absolute;
|
||||
width: 440px;
|
||||
height: 440px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 20; /* above text during overlap */
|
||||
opacity: 0; /* fromTo will animate */
|
||||
}
|
||||
.hero .logo-mark {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
border-radius: 52px;
|
||||
box-shadow:
|
||||
0 0 70px rgba(24, 217, 232, 0.28),
|
||||
0 0 120px rgba(123, 234, 90, 0.18);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="root"
|
||||
data-composition-id="main"
|
||||
data-start="0"
|
||||
data-duration="7.5"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
>
|
||||
<div
|
||||
class="stage clip"
|
||||
data-start="0"
|
||||
data-duration="7.5"
|
||||
data-track-index="1"
|
||||
id="displace-stage"
|
||||
>
|
||||
<!-- Text group: typewriter + ticker -->
|
||||
<div class="text-group" id="text-group">
|
||||
<div class="typewriter">
|
||||
<span class="typewriter-text">Hyp</span>
|
||||
</div>
|
||||
<div class="ticker-window">
|
||||
<div class="ticker-stack">
|
||||
<div class="ticker-item">HTML</div>
|
||||
<div class="ticker-item">motion</div>
|
||||
<div class="ticker-item">video</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hero logo asset -->
|
||||
<div class="hero" id="hero-logo">
|
||||
<!-- Inline-SVG placeholder mark — swap for your logo image -->
|
||||
<svg class="logo-mark" viewBox="0 0 100 100" role="img" aria-label="HyperFrames">
|
||||
<defs>
|
||||
<linearGradient id="hfMark" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#8b7bff" />
|
||||
<stop offset="1" stop-color="#3ddc97" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="4" y="4" width="92" height="92" rx="22" fill="url(#hfMark)" />
|
||||
<text
|
||||
x="50"
|
||||
y="63"
|
||||
text-anchor="middle"
|
||||
font-family="Inter, system-ui, sans-serif"
|
||||
font-size="40"
|
||||
font-weight="800"
|
||||
fill="#fff"
|
||||
>
|
||||
HF
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ================================================================
|
||||
CONSTANTS
|
||||
================================================================ */
|
||||
const TOTAL_DURATION = 7.5;
|
||||
const FONT_SIZE = 140;
|
||||
const ITEM_HEIGHT = FONT_SIZE * 1.2; // = 168 px
|
||||
|
||||
const TIMING = {
|
||||
// Phase 1: typewriter
|
||||
typeStart: 0.0,
|
||||
typeDur: 0.67,
|
||||
typeStartLen: 3, // pre-render the first 3 chars
|
||||
|
||||
// Phase 2: ticker steps
|
||||
ticker1At: 1.67, // HTML → motion
|
||||
ticker2At: 3.33, // motion → video
|
||||
stepDur: 0.55,
|
||||
|
||||
// Phase 3: reactive displacement
|
||||
displaceAt: 4.6,
|
||||
heroDur: 0.85, // matches spring(mass:1.5) settle
|
||||
offscreenX: 800, // hero starts here
|
||||
pushDist: -150, // text pushed THIS direction
|
||||
|
||||
// Phase 4: breathing
|
||||
idleStart: 6.6, // displaceAt + heroDur + ~1.15s buffer
|
||||
};
|
||||
|
||||
const FULL_TEXT = "HyperFrames turns";
|
||||
|
||||
/* ================================================================
|
||||
TIMELINE
|
||||
================================================================ */
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
window.__timelines["main"] = tl;
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
PHASE 1: Typewriter (smooth slice)
|
||||
---------------------------------------------------------------- */
|
||||
const typewriterEl = document.querySelector(".typewriter-text");
|
||||
const typeProxy = { progress: TIMING.typeStartLen };
|
||||
|
||||
tl.to(
|
||||
typeProxy,
|
||||
{
|
||||
progress: FULL_TEXT.length,
|
||||
duration: TIMING.typeDur,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
const len = Math.floor(typeProxy.progress);
|
||||
const next = FULL_TEXT.slice(0, len);
|
||||
if (typewriterEl.textContent !== next) typewriterEl.textContent = next;
|
||||
},
|
||||
},
|
||||
TIMING.typeStart,
|
||||
);
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
PHASE 2: Vertical ticker (2 steps: HTML → motion → video)
|
||||
---------------------------------------------------------------- */
|
||||
tl.to(
|
||||
".ticker-stack",
|
||||
{
|
||||
y: `-=${ITEM_HEIGHT}`,
|
||||
duration: TIMING.stepDur,
|
||||
ease: "back.out(1.4)", // spring(stiffness:120, damping:14)
|
||||
},
|
||||
TIMING.ticker1At,
|
||||
);
|
||||
|
||||
tl.to(
|
||||
".ticker-stack",
|
||||
{
|
||||
y: `-=${ITEM_HEIGHT}`,
|
||||
duration: TIMING.stepDur,
|
||||
ease: "back.out(1.4)",
|
||||
},
|
||||
TIMING.ticker2At,
|
||||
);
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
PHASE 3: Reactive displacement — three concurrent tweens.
|
||||
---------------------------------------------------------------- */
|
||||
// (1) Hero enters with rotation + scale impact, lands at scale 1.3.
|
||||
tl.fromTo(
|
||||
".hero",
|
||||
{ x: TIMING.offscreenX, scale: 0.5, rotation: -45, opacity: 0 },
|
||||
{
|
||||
x: 0,
|
||||
scale: 1.3,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
duration: TIMING.heroDur,
|
||||
ease: "power2.out", // spring(stiffness:100, damping:20, mass:1.5)
|
||||
},
|
||||
TIMING.displaceAt,
|
||||
);
|
||||
|
||||
// (2) Text pushed left. Completes at 50% of hero duration → immediate impact.
|
||||
tl.to(
|
||||
".text-group",
|
||||
{ x: TIMING.pushDist, duration: TIMING.heroDur * 0.5, ease: "power2.out" },
|
||||
TIMING.displaceAt,
|
||||
);
|
||||
|
||||
// (3) Text fades. Completes at 40% → fades slightly before push lands.
|
||||
tl.to(
|
||||
".text-group",
|
||||
{ opacity: 0, duration: TIMING.heroDur * 0.4, ease: "power2.out" },
|
||||
TIMING.displaceAt,
|
||||
);
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
PHASE 4: Breathing — onUpdate so it MULTIPLIES onto the 1.3 scale.
|
||||
Dual frequencies (1.0s scale, 1.33s rotation) for organic motion.
|
||||
---------------------------------------------------------------- */
|
||||
const heroEl = document.querySelector(".hero");
|
||||
const HERO_FINAL_SCALE = 1.3;
|
||||
const HERO_FINAL_ROTATION = 0;
|
||||
const SCALE_PERIOD = 1.0; // seconds per scale cycle
|
||||
const ROTATE_PERIOD = 1.333; // seconds per rotation cycle — not a simple ratio of SCALE_PERIOD
|
||||
const SCALE_AMP = 0.05;
|
||||
const ROTATE_AMP = 3;
|
||||
|
||||
const breathDur = TOTAL_DURATION - TIMING.idleStart;
|
||||
|
||||
tl.to(
|
||||
{ tick: 0 },
|
||||
{
|
||||
tick: 1,
|
||||
duration: breathDur,
|
||||
ease: "none",
|
||||
onUpdate: function () {
|
||||
const idleTime = Math.max(0, tl.time() - TIMING.idleStart);
|
||||
const omegaS = (idleTime / SCALE_PERIOD) * Math.PI * 2;
|
||||
const omegaR = (idleTime / ROTATE_PERIOD) * Math.PI * 2;
|
||||
gsap.set(heroEl, {
|
||||
scale: HERO_FINAL_SCALE * (1 + Math.sin(omegaS) * SCALE_AMP),
|
||||
rotation: HERO_FINAL_ROTATION + Math.sin(omegaR) * ROTATE_AMP,
|
||||
});
|
||||
},
|
||||
},
|
||||
TIMING.idleStart,
|
||||
);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,606 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=1920, height=1080" />
|
||||
<title>Scene — HyperFrames Workflow Approve & Press</title>
|
||||
|
||||
<!--
|
||||
Blueprint: workflow-approve-press
|
||||
HyperFrames composition.tsx.
|
||||
Same four-phase choreography, GSAP-driven, seek-safe.
|
||||
|
||||
Audio (assumed accompanying narration):
|
||||
"This is AI that renders with you, not just for you.
|
||||
Preview the result, render to MP4, then confirm."
|
||||
|
||||
Sub-phases (local time, 5.5s total):
|
||||
0.17 – 0.72s Headline "AI edits WITH you" slides down
|
||||
0.50 – 1.10s Center editor mockup scales in
|
||||
0.67 – 2.17s Step indicators stagger-enter on the left (3D-tilted)
|
||||
2.00 – 3.33s Step state machine: 1→complete, 2→active … 2→complete, 3→active
|
||||
3.52 – 4.02s Confirm MP4 button (3D-tilted right) bouncy entry after render step is active
|
||||
4.22 – 4.72s Button depression (linear, no overshoot)
|
||||
4.72 – 5.22s Render confirmed: step 3 completes, color shifts to success green + checkmark pop
|
||||
|
||||
Asset placeholders:
|
||||
./assets/editor-demo.mp4 — center product demo video. Replace with a
|
||||
real export of the AI-editor flow. The
|
||||
composition falls back to a CSS-mockup of
|
||||
an editor timeline so it renders without
|
||||
network or media files.
|
||||
-->
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
/* Palette — port of the the source COLORS / TYPOGRAPHY constants */
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #cbd5e1;
|
||||
--text-muted: #94a3b8;
|
||||
--brand-purple: #a78bfa;
|
||||
--brand-purple-glow: rgba(167, 139, 250, 0.6);
|
||||
--brand-green: #22c55e;
|
||||
--brand-green-glow: rgba(34, 197, 94, 0.6);
|
||||
--panel-stroke: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
font-family:
|
||||
"Inter",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
BACKGROUND — port of <FrostedGlassBackground />
|
||||
============================================================ */
|
||||
.bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse at 25% 30%, rgba(167, 139, 250, 0.2), transparent 55%),
|
||||
radial-gradient(ellipse at 80% 70%, rgba(34, 197, 94, 0.12), transparent 55%),
|
||||
linear-gradient(135deg, #1a1530 0%, #0a0815 70%);
|
||||
}
|
||||
|
||||
/* Ambient warm glow on the right (button side) */
|
||||
.ambient {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(
|
||||
ellipse at 70% 50%,
|
||||
var(--brand-purple-glow) 0%,
|
||||
transparent 40%
|
||||
);
|
||||
opacity: 0.15;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Vignette */
|
||||
.vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse at center, transparent 40%, rgba(0, 0, 0, 0.4) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
PHASE 1 — HEADLINE
|
||||
============================================================ */
|
||||
.headline-wrap {
|
||||
position: absolute;
|
||||
top: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
opacity: 0; /* GSAP fades in */
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.headline {
|
||||
font-size: 96px;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.headline .accent {
|
||||
color: var(--brand-purple);
|
||||
text-shadow: 0 0 30px var(--brand-purple-glow);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
PHASE 2 — CENTER DEMO
|
||||
(Falls back to a CSS-mockup editor when ./assets/editor-demo.mp4
|
||||
is missing. The mockup is purely decorative — no animation
|
||||
inside it; the eye reads it as a paused product screenshot.)
|
||||
============================================================ */
|
||||
.demo-wrap {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 60%;
|
||||
opacity: 0;
|
||||
}
|
||||
.demo-frame {
|
||||
width: 1000px;
|
||||
height: 600px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
border: 2px solid var(--panel-stroke);
|
||||
box-shadow:
|
||||
0 0 40px var(--brand-purple-glow),
|
||||
0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
position: relative;
|
||||
}
|
||||
.demo-frame video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* CSS-mockup fallback for the editor demo */
|
||||
.demo-mockup {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
grid-template-rows: 56px 1fr 100px;
|
||||
background: linear-gradient(180deg, #1f1b3a 0%, #15112a 100%);
|
||||
}
|
||||
.mockup-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 20px;
|
||||
background: #15112a;
|
||||
border-bottom: 1px solid var(--panel-stroke);
|
||||
}
|
||||
.mockup-toolbar .dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
.mockup-toolbar .dot.r {
|
||||
background: #ef4444;
|
||||
}
|
||||
.mockup-toolbar .dot.y {
|
||||
background: #eab308;
|
||||
}
|
||||
.mockup-toolbar .dot.g {
|
||||
background: #22c55e;
|
||||
}
|
||||
.mockup-toolbar .title {
|
||||
margin-left: 18px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.mockup-canvas {
|
||||
position: relative;
|
||||
padding: 30px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.04) 0 1px, transparent 1px 40px),
|
||||
repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.04) 0 1px, transparent 1px 40px);
|
||||
}
|
||||
.mockup-canvas .preview-box {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, rgba(167, 139, 250, 0.35), rgba(34, 197, 94, 0.2));
|
||||
border: 1px solid var(--panel-stroke);
|
||||
box-shadow: inset 0 0 60px rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.mockup-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 14px 20px;
|
||||
background: #15112a;
|
||||
border-top: 1px solid var(--panel-stroke);
|
||||
}
|
||||
.mockup-timeline .track {
|
||||
height: 24px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.mockup-timeline .clip {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.mockup-timeline .t1 .a {
|
||||
flex: 3;
|
||||
background: var(--brand-purple);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.mockup-timeline .t1 .b {
|
||||
flex: 2;
|
||||
background: var(--brand-purple);
|
||||
opacity: 0.55;
|
||||
}
|
||||
.mockup-timeline .t1 .c {
|
||||
flex: 4;
|
||||
background: var(--brand-purple);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.mockup-timeline .t2 .a {
|
||||
flex: 5;
|
||||
background: var(--brand-green);
|
||||
opacity: 0.75;
|
||||
}
|
||||
.mockup-timeline .t2 .b {
|
||||
flex: 2;
|
||||
background: var(--brand-green);
|
||||
opacity: 0.45;
|
||||
}
|
||||
.mockup-timeline .t2 .c {
|
||||
flex: 2;
|
||||
background: var(--brand-green);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
PHASE 3 — LEFT FLANK: STEP INDICATORS (3D-TILTED)
|
||||
============================================================ */
|
||||
.steps-flank {
|
||||
position: absolute;
|
||||
left: 100px;
|
||||
top: 55%;
|
||||
transform: translateY(-50%) perspective(800px) rotateY(15deg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
}
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
opacity: 0; /* GSAP fades in */
|
||||
}
|
||||
.step-circle {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid var(--text-muted);
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
transition: none; /* Steps SNAP between states, no CSS transition */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.step-num {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.step-check {
|
||||
display: none;
|
||||
}
|
||||
.step-label {
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* State machine — toggled via tl.set({ attr: data-state }) */
|
||||
.step[data-state="active"] .step-circle {
|
||||
border-color: var(--brand-purple);
|
||||
box-shadow: 0 0 15px var(--brand-purple-glow);
|
||||
}
|
||||
.step[data-state="active"] .step-num {
|
||||
color: var(--brand-purple);
|
||||
}
|
||||
.step[data-state="active"] .step-label {
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.step[data-state="complete"] .step-circle {
|
||||
border-color: var(--brand-green);
|
||||
background-color: var(--brand-green);
|
||||
}
|
||||
.step[data-state="complete"] .step-num {
|
||||
display: none;
|
||||
}
|
||||
.step[data-state="complete"] .step-check {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
PHASE 4 — RIGHT FLANK: CONFIRM BUTTON (3D-TILTED)
|
||||
============================================================ */
|
||||
.button-flank {
|
||||
position: absolute;
|
||||
right: 70px;
|
||||
top: 55%;
|
||||
transform: translateY(-50%) perspective(800px) rotateY(-15deg);
|
||||
}
|
||||
.btn-press {
|
||||
opacity: 0;
|
||||
transform-origin: center center;
|
||||
}
|
||||
.btn {
|
||||
--btn-glow-blur: 20px;
|
||||
padding: 20px 50px;
|
||||
border-radius: 12px;
|
||||
background-color: var(--brand-purple);
|
||||
box-shadow: 0 0 var(--btn-glow-blur) var(--brand-purple-glow);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: var(--text-primary);
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-check {
|
||||
display: inline-flex;
|
||||
transform-origin: center center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="root"
|
||||
data-composition-id="interactive-workflow"
|
||||
data-start="0"
|
||||
data-duration="5.5"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
style="position: relative; width: 1920px; height: 1080px; overflow: hidden"
|
||||
>
|
||||
<!-- Background layers -->
|
||||
<div class="bg"></div>
|
||||
|
||||
<!-- PHASE 1: Headline -->
|
||||
<div class="headline-wrap">
|
||||
<div class="headline">
|
||||
HyperFrames builds
|
||||
<span class="accent">WITH</span>
|
||||
you
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PHASE 2: Center demo (video with CSS-mockup fallback) -->
|
||||
<div class="demo-wrap">
|
||||
<div class="demo-frame">
|
||||
<!--
|
||||
Real asset path (replace for production).
|
||||
If missing, the CSS mockup below shows through the transparent
|
||||
poster region. The video stays muted per HyperFrames rules.
|
||||
-->
|
||||
<!-- Decorative CSS-mockup of an editor — paused screenshot feel. -->
|
||||
<div class="demo-mockup">
|
||||
<div class="mockup-toolbar">
|
||||
<span class="dot r"></span><span class="dot y"></span><span class="dot g"></span>
|
||||
<span class="title">HyperFrames — Project: Launch Promo</span>
|
||||
</div>
|
||||
<div class="mockup-canvas">
|
||||
<div class="preview-box">Preview · 1920 × 1080</div>
|
||||
</div>
|
||||
<div class="mockup-timeline">
|
||||
<div class="track t1">
|
||||
<div class="clip a"></div>
|
||||
<div class="clip b"></div>
|
||||
<div class="clip c"></div>
|
||||
</div>
|
||||
<div class="track t2">
|
||||
<div class="clip a"></div>
|
||||
<div class="clip b"></div>
|
||||
<div class="clip c"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PHASE 3: Left-flank step indicators (3D-tilted +15°) -->
|
||||
<div class="steps-flank">
|
||||
<div class="step step-1" data-step="1" data-state="pending">
|
||||
<div class="step-circle">
|
||||
<span class="step-num">1</span>
|
||||
<svg class="step-check" width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M20 6L9 17L4 12" stroke="#fff" stroke-width="2.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="step-label">Compose HTML Scene</span>
|
||||
</div>
|
||||
|
||||
<div class="step step-2" data-step="2" data-state="pending">
|
||||
<div class="step-circle">
|
||||
<span class="step-num">2</span>
|
||||
<svg class="step-check" width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M20 6L9 17L4 12" stroke="#fff" stroke-width="2.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="step-label">Seek & Preview</span>
|
||||
</div>
|
||||
|
||||
<div class="step step-3" data-step="3" data-state="pending">
|
||||
<div class="step-circle">
|
||||
<span class="step-num">3</span>
|
||||
<svg class="step-check" width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M20 6L9 17L4 12" stroke="#fff" stroke-width="2.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="step-label">Render to MP4</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PHASE 4: Right-flank confirm button (3D-tilted -15°) -->
|
||||
<div class="button-flank">
|
||||
<div class="btn-press">
|
||||
<div class="btn">
|
||||
<span class="btn-check">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M20 6L9 17L4 12"
|
||||
stroke="#fff"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="btn-label">Confirm MP4</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ambient + vignette overlays (pointer-events: none) -->
|
||||
<div class="ambient"></div>
|
||||
<div class="vignette"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// ── Phase boundaries (seconds) — match the blueprint ────────────
|
||||
const HEADLINE_START = 0.17;
|
||||
const HEADLINE_END = 0.72;
|
||||
const VIDEO_START = 0.5;
|
||||
const STEPS_START = 0.67;
|
||||
const STEP_STAGGER = 0.5;
|
||||
const STEP_ACTIVE_T2 = 2.0; // step 1 → complete, step 2 → active
|
||||
const STEP_ACTIVE_T3 = 3.33; // step 2 → complete, step 3 → active
|
||||
const BUTTON_ENTER = 3.52; // after "Render to MP4" becomes active
|
||||
const PRESS_FRAME = 4.22;
|
||||
const PRESS_DURATION = 0.5;
|
||||
const CHECK_POP = PRESS_FRAME + PRESS_DURATION;
|
||||
const SCENE_END = 5.5; // matches data-duration on the root
|
||||
|
||||
// ── Phase 1: Headline slides down from top ─────────────────────
|
||||
gsap.set(".demo-wrap", { xPercent: -50, yPercent: -50, scale: 0.8, opacity: 0 });
|
||||
gsap.set(".step", { x: -30, opacity: 0 });
|
||||
gsap.set(".btn-press", { scale: 0, opacity: 0 });
|
||||
gsap.set(".btn-check", { scale: 0 });
|
||||
|
||||
tl.fromTo(
|
||||
".headline-wrap",
|
||||
{ y: -40, opacity: 0 },
|
||||
{
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
duration: HEADLINE_END - HEADLINE_START,
|
||||
ease: "back.out(1.2)", // ≈ the source SPRING_CONFIGS.entrance
|
||||
},
|
||||
HEADLINE_START,
|
||||
);
|
||||
|
||||
// ── Phase 2: Center demo scales in ─────────────────────────────
|
||||
// HyperFrames syncs the <video> element's currentTime to the seek
|
||||
// position automatically — no .play() call needed (and one here
|
||||
// would break deterministic rendering).
|
||||
tl.to(".demo-wrap", { scale: 1, opacity: 1, duration: 0.6, ease: "power3.out" }, VIDEO_START);
|
||||
|
||||
// ── Phase 3a: Stagger entry of the step indicators ─────────────
|
||||
tl.to(
|
||||
".step",
|
||||
{
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
duration: 0.4,
|
||||
ease: "power3.out",
|
||||
stagger: { each: STEP_STAGGER, from: "start" },
|
||||
},
|
||||
STEPS_START,
|
||||
);
|
||||
|
||||
// ── Phase 3b: Step state machine (snap-toggled here) ──
|
||||
// Step 1 enters in the "active" state because narration starts on it.
|
||||
tl.set(".step-1", { attr: { "data-state": "active" } }, STEPS_START);
|
||||
|
||||
// First transition — step 1 finishes, step 2 takes over.
|
||||
tl.set(".step-1", { attr: { "data-state": "complete" } }, STEP_ACTIVE_T2);
|
||||
tl.set(".step-2", { attr: { "data-state": "active" } }, STEP_ACTIVE_T2);
|
||||
|
||||
// Second transition — step 2 finishes, step 3 takes over.
|
||||
tl.set(".step-2", { attr: { "data-state": "complete" } }, STEP_ACTIVE_T3);
|
||||
tl.set(".step-3", { attr: { "data-state": "active" } }, STEP_ACTIVE_T3);
|
||||
|
||||
// ── Phase 4a: Button bouncy entry ──────────────────────────────
|
||||
tl.to(
|
||||
".btn-press",
|
||||
{ scale: 1, opacity: 1, duration: 0.5, ease: "back.out(1.4)", overwrite: "auto" },
|
||||
BUTTON_ENTER,
|
||||
);
|
||||
|
||||
// ── Phase 4b: Press (linear depression → linear return) ────────
|
||||
// Two adjacent tweens on .btn-press — end value of (1) = start value of (2).
|
||||
tl.to(".btn-press", { scale: 0.95, duration: 0.1, ease: "power1.out" }, PRESS_FRAME);
|
||||
tl.to(
|
||||
".btn-press",
|
||||
{ scale: 1.0, duration: PRESS_DURATION - 0.1, ease: "power1.in" },
|
||||
PRESS_FRAME + 0.1,
|
||||
);
|
||||
|
||||
// ── Phase 4c: Color shift + label swap (at press end) ──────────
|
||||
tl.to(
|
||||
".btn",
|
||||
{
|
||||
backgroundColor: "#15803d",
|
||||
boxShadow: "0 0 25px rgba(21, 128, 61, 0.68)",
|
||||
duration: 0.3,
|
||||
ease: "power2.out",
|
||||
},
|
||||
CHECK_POP,
|
||||
);
|
||||
tl.set(".btn-label", { textContent: "Approved!" }, CHECK_POP);
|
||||
tl.set(".step-3", { attr: { "data-state": "complete" } }, CHECK_POP);
|
||||
|
||||
// ── Phase 4d: Checkmark pops into view ─────────────────────────
|
||||
// back.out(1.6) ≈ the source spring({ stiffness: 200, damping: 15 }).
|
||||
tl.to(".btn-check", { scale: 1, duration: 0.5, ease: "back.out(1.6)" }, CHECK_POP);
|
||||
|
||||
// ── Ambient pulsing glow on the button (Math.sin) ──
|
||||
// Source: `glowIntensity = 20 + Math.sin(frame * 0.1) * 10` — i.e. blur
|
||||
// oscillates 10 ↔ 30 around 20, with angular freq 0.1 rad/frame.
|
||||
// At 60fps that's 6 rad/s, so full period ≈ 2π / 6 ≈ 1.05s.
|
||||
// Finite yoyo replaces the continuous sine; `box-shadow` reads the
|
||||
// tweened CSS variable so the blur is actually animatable.
|
||||
const PULSE_PERIOD = 1.05;
|
||||
const PULSE_HALVES = Math.max(2, Math.floor((SCENE_END - BUTTON_ENTER) / (PULSE_PERIOD / 2)));
|
||||
tl.fromTo(
|
||||
".btn",
|
||||
{ "--btn-glow-blur": "10px" }, // source amplitude: 20 ± 10
|
||||
{
|
||||
"--btn-glow-blur": "30px",
|
||||
duration: PULSE_PERIOD / 2,
|
||||
ease: "sine.inOut",
|
||||
yoyo: true,
|
||||
repeat: PULSE_HALVES - 1,
|
||||
},
|
||||
BUTTON_ENTER,
|
||||
);
|
||||
|
||||
window.__timelines["interactive-workflow"] = tl;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,86 @@
|
||||
# Rules Index
|
||||
|
||||
Atomic motion recipes. Each lives at `rules/<name>.md`. Compose 2-4 per scene with a single paused timeline.
|
||||
|
||||
## Text & Typography
|
||||
|
||||
<rules>
|
||||
<hacker-flip-3d path="rules/hacker-flip-3d.md">Character-level 3D rotation with deterministic glyph substitution (decryption). GSAP `back.out` ease + per-glyph `onUpdate` for the flicker hash. Tags: text, 3d, reveal, decode</hacker-flip-3d>
|
||||
<vertical-spring-ticker path="rules/vertical-spring-ticker.md">Slot-machine vertical scrolling using stepped GSAP tweens within a masked column. Tags: text, ticker, scroll, vertical</vertical-spring-ticker>
|
||||
<counting-dynamic-scale path="rules/counting-dynamic-scale.md">Counter where font size grows with the value for escalating emphasis. Single GSAP tween on a numeric proxy. Tags: counter, scale, font-size, number, dynamic</counting-dynamic-scale>
|
||||
<discrete-text-sequence path="rules/discrete-text-sequence.md">Replace entire text states at time thresholds for non-linear typing (typos, holds, bulk additions, backspaces). GSAP onUpdate-driven reverse search. Tags: text, typing, discrete, threshold, non-linear</discrete-text-sequence>
|
||||
<asr-keyword-glow path="rules/asr-keyword-glow.md">Highlight keywords with glow + scale + color synced to ASR word timestamps. Two GSAP tweens per word drive a CSS custom property `--glow` through attack-decay-rest envelope. Tags: asr, audio-sync, highlight, glow, keyword, text</asr-keyword-glow>
|
||||
<3d-text-depth-layers path="rules/3d-text-depth-layers.md">Multiple offset text layers (N divs at `(i*dx, i*dy)` with decreasing alpha) create a stacked 3D extrusion illusion on large typography. Tags: text, 3d, depth, layers, shadow, typography, stacked</3d-text-depth-layers>
|
||||
<context-sensitive-cursor path="rules/context-sensitive-cursor.md">Typing cursor whose `background-color` switches at segment boundaries plus square-wave blink via `(tl.time() % cycle) < cycle/2`. Tags: cursor, color, context, typewriter, styling, segment</context-sensitive-cursor>
|
||||
<dynamic-content-sequencing path="rules/dynamic-content-sequencing.md">Pre-compute a flat `[{startTime, endTime, ...}]` array from a script of `{textMain, textAccent, charSpeed, hold}` entries. Each phrase's window = `chars × charSpeed + hold`. Content-driven duration, no hand-tuned offsets. Tags: timeline, sequencing, dynamic, duration, script-driven</dynamic-content-sequencing>
|
||||
<kinetic-beat-slam path="rules/kinetic-beat-slam.md">Percussive kinetic typography — short phrases slam in on ONE shared beat array with DISTINCT per-phrase entrances (scale-slam / side-snap / rise-rotate), optional rhythm chrome (metronome ticks, beat bar), then a locked finale. The recipe for "punchy / rhythmic" taglines. Tags: text, kinetic, typography, beat, rhythm, slam, percussive, punchy</kinetic-beat-slam>
|
||||
</rules>
|
||||
|
||||
## Data & Stats
|
||||
|
||||
<rules>
|
||||
<counting-dynamic-scale path="rules/counting-dynamic-scale.md">Counter whose font size grows with the value; seek-safe `onUpdate`, `Math.round`, `tabular-nums`, multi-stat chord. (Also listed under Text & Typography.) Tags: counter, number, stat, count-up</counting-dynamic-scale>
|
||||
<stat-bars-and-fills path="rules/stat-bars-and-fills.md">Data-viz primitives that pair a number with a graphic — growth bars (CSS `scaleY` stagger), progress fill (bar `scaleX` or measured SVG ring), and fractional star-rating wipe (`clip-path`). Transforms only, seek-safe. Pick single-focus vs split-frame and hold it. Tags: data, stats, chart, bars, progress, ring, stars, rating, infographic</stat-bars-and-fills>
|
||||
</rules>
|
||||
|
||||
## Camera & Viewport
|
||||
|
||||
<rules>
|
||||
<coordinate-target-zoom path="rules/coordinate-target-zoom.md">Zoom into non-centered elements via scale (outer wrapper) + counter-translation (inner wrapper). Tags: camera, zoom, scale, translate</coordinate-target-zoom>
|
||||
<camera-cursor-tracking path="rules/camera-cursor-tracking.md">Two-phase virtual camera that locks the viewport to a moving focal point (typing cursor) — static initial framing then focal-point-locked tracking. Uses browser-native `getBoundingClientRect()` / `ctx.measureText()` after `document.fonts.ready`. Tags: camera, tracking, viewport, two-phase, typing</camera-cursor-tracking>
|
||||
<multi-phase-camera path="rules/multi-phase-camera.md">Sequential camera-zoom system (pull-back / focus / push) plus continuous micro-drift. Tags: camera, zoom, phase, drift, scale, cinematic</multi-phase-camera>
|
||||
<viewport-change path="rules/viewport-change.md">Virtual camera — simulate zoom / pan / focus-lock by transforming a single `.world` wrapper containing all scene content. Single-element composite transform `translate(x,y) scale(S)`; counter-translate math is `T = -offset × S` (DIFFERENT from coordinate-target-zoom's `T = -offset`). Tags: viewport, camera, zoom, pan, focus-lock</viewport-change>
|
||||
<depth-of-field-blur path="rules/depth-of-field-blur.md">Selective rack-focus — GSAP-tween `filter: blur()` (+ slight opacity dim) on off-focus layers via a `--dof` var while the focal element stays sharp; single pull, two-plane rack, or blur-the-cluster-while-pushing-in. Finite, deterministic, seek-safe. Tags: blur, depth-of-field, focus, rack-focus, dim, spotlight</depth-of-field-blur>
|
||||
</rules>
|
||||
|
||||
## Layout & Network
|
||||
|
||||
<rules>
|
||||
<avatar-cloud-network path="rules/avatar-cloud-network.md">Avatars on an elliptical ring with SVG connection lines to a center point, staggered entry. Cloud center coordinates must match the centerpiece element exactly. Tags: avatar, cloud, network, social-proof, stagger</avatar-cloud-network>
|
||||
<3d-page-scroll path="rules/3d-page-scroll.md">Full webpage rendered as a tilted 3D card whose internal content scrolls to reveal specific sections. Pair with asr-keyword-glow for on-page keyword highlighting. Tags: 3d, page, scroll, webpage, tilt, perspective, product-demo</3d-page-scroll>
|
||||
<center-outward-expansion path="rules/center-outward-expansion.md">Elements start clustered at screen center and expand outward to final positions. Each element gets its target position via CSS once; GSAP tweens transform `x` / `y` offsets to 0 in lockstep with a shared driver. Tags: expansion, scatter, center, reveal, layout, sync</center-outward-expansion>
|
||||
<split-tilt-cards path="rules/split-tilt-cards.md">Two cards side-by-side with opposing rotationY tilts (+/- baseTilt) and entry slides from their respective sides. Continuous floating runs in phase opposition (`Math.PI` offset). Tags: 3d, cards, split, tilt, comparison, symmetric</split-tilt-cards>
|
||||
<orbit-3d-entry path="rules/orbit-3d-entry.md">Elements flip in from 3D space (`rotateX` + `rotateY` + `translateZ`) then settle into a continuous elliptical orbit. **Critical**: entry MUST flip in-place at the orbital starting position (`gsap.set` BEFORE phase 1), not at scene center. Tags: orbit, 3d, flip, ellipse, circular, icon, entry, continuous</orbit-3d-entry>
|
||||
<ai-tracking-box path="rules/ai-tracking-box.md">AI detection overlay — yellow `#facc15` L-bracket corners + confidence label (fluctuating 95-99%) following a target on a sine arc path. Box position recomputed per-frame from target position (never tweened separately). Tags: ai, tracking, bounding-box, detection, corner, ml</ai-tracking-box>
|
||||
<depth-scatter-assemble path="rules/depth-scatter-assemble.md">N elements scatter into / reassemble from a rotating 3D depth-cloud — each starts at a deterministic index-derived 3D offset (translateZ + rotateX/Y + scatter) and settles to a clean flat layout; tumble-swap and radial-explode variants. preserve-3d + perspective, transform-only, seek-safe. Tags: 3d, scatter, assemble, tumble, depth, perspective, glyphs</depth-scatter-assemble>
|
||||
</rules>
|
||||
|
||||
## SVG & Icons
|
||||
|
||||
<rules>
|
||||
<svg-icon-enrichment path="rules/svg-icon-enrichment.md">Animate internal SVG elements (rotating hands, oscillating blades, pulsing dots, dash-flow lines) so icons feel alive. **Critical**: use SVG `setAttribute('transform', 'rotate(deg cx cy)')` for explicit center — CSS `transform-origin` + `transform-box: fill-box` interprets origin in bbox-local coords (off-center for thin lines). Tags: svg, icon, animation, micro-animation, rotation, pulse</svg-icon-enrichment>
|
||||
<svg-path-draw path="rules/svg-path-draw.md">SVG outline draws itself stroke-by-stroke via `stroke-dasharray` / `stroke-dashoffset`. Measure with `getTotalLength()` at composition setup, set initial dashoffset = length, GSAP tweens to 0. For circular progress rings, rotate the stroke `-90deg` so drawing starts at 12 o'clock. Tags: svg, stroke, draw, vector, path, dasharray</svg-path-draw>
|
||||
</rules>
|
||||
|
||||
## Idle & Ambient
|
||||
|
||||
<rules>
|
||||
<sine-wave-loop path="rules/sine-wave-loop.md">Continuous breathing/idle ambient motion. Two forms: GSAP `sine.inOut` yoyo with finite repeats (preferred when standalone) or onUpdate reading `tl.time()` (preferred when multiplying onto another live value). Tags: idle, loop, breathing, sine, ambient</sine-wave-loop>
|
||||
<ambient-glow-bloom path="rules/ambient-glow-bloom.md">Un-triggered soft radial glow that blooms in behind a hero element and holds with a bounded idle breathe, or a single-pass traveling sheen across a surface. No click, no word-sync; peak opacity ≤ ~0.45, finite/deterministic. Tags: glow, bloom, ambient, radial, sheen, hero</ambient-glow-bloom>
|
||||
</rules>
|
||||
|
||||
## Transition & Motion
|
||||
|
||||
<rules>
|
||||
<reactive-displacement path="rules/reactive-displacement.md">Physical-collision transition where an entering element's GSAP tween drives the exiting element's displacement. Three concurrent tweens at the same timeline position with victim durations 40-50% of the intruder's. Tags: transition, physics, collision, displacement, push</reactive-displacement>
|
||||
<press-release-spring path="rules/press-release-spring.md">Tactile button press: linear compression then spring recovery via two adjacent GSAP tweens on the same property. Variations: color transition, shadow depth via CSS vars, release burst, background glow. Tags: spring, press, button, interaction, physics, glow, burst</press-release-spring>
|
||||
<physics-press-reaction path="rules/physics-press-reaction.md">Physical click simulation — two sequential GSAP scale tweens (down to 0.9, up to 1.0) approximate a spring with overshoot. Pass a single targets array `["#cta", "#cursor"]` to compress both together for tactile contact feel. Tags: spring, click, physics, press, interaction, cursor</physics-press-reaction>
|
||||
<cursor-click-ripple path="rules/cursor-click-ripple.md">Animated cursor moves to a target, depresses cursor + target together on click, emits an expanding ripple with attack-decay opacity envelope. Element lives in DOM from t=0 with `opacity: 0` (no conditional rendering). Tags: cursor, click, ripple, interaction, mouse, button, keyframes</cursor-click-ripple>
|
||||
<scale-swap-transition path="rules/scale-swap-transition.md">Coordinated morph between two DOM elements at the same screen center. Exit cluster shrinks + fades; entrance pops in with `back.out(2)` overshoot. Tags: transition, morph, scale, swap</scale-swap-transition>
|
||||
<card-morph-anchor path="rules/card-morph-anchor.md">Container morphs apparent size + corner radius + surface treatment between two shots, then fades to reveal the real target underneath. HyperFrames substitutes uniform `scale` for the forbidden `width`/`height` tween, plus paint-only `borderRadius`/`background`/`boxShadow`. Tags: morph, anchor, transition, border-radius, container, shape, handoff</card-morph-anchor>
|
||||
<spring-pop-entrance path="rules/spring-pop-entrance.md">The canonical ENTRANCE pop — an element (or staggered group) arrives by springing `scale: 0 → 1` with `back.out` overshoot, `fromTo` so it's correct at t=0 under seek. Single hero, staggered group (≤500ms cap), overshoot tuned by personality. Distinct from `press-release-spring` (a click/press reaction). Tags: spring, entrance, pop, scale-in, overshoot, stagger, arrival</spring-pop-entrance>
|
||||
<motion-blur-streak path="rules/motion-blur-streak.md">Fake directional velocity blur on a fast entrance / camera push-through — blur peaks at max speed, resolves to 0 at the settle. Two paths: SVG `feGaussianBlur` stdDeviation on the motion axis (proxy-tweened), or a deterministic echo/ghost trail that collapses into the lead. Entrances / mid-shot only. Tags: motion-blur, streak, velocity, ghost, echo, fast</motion-blur-streak>
|
||||
</rules>
|
||||
|
||||
## Effect Recipes (moved from hyperframes-creative)
|
||||
|
||||
<rules>
|
||||
<gsap-effects path="rules/gsap-effects.md">Drop-in GSAP timeline patterns — typewriter, audio visualizer, and other reusable choreography blocks. Tags: gsap, recipe, drop-in, typewriter, audio-visualizer</gsap-effects>
|
||||
<css-marker-patterns path="rules/css-marker-patterns.md">Pure CSS + GSAP implementations of marker-highlight drawing modes — highlight (yellow sweep), circle (hand-drawn ellipse), burst (radiating lines), scribble (chaotic), sketchout (rough rectangle outline). Tags: css, marker, highlight, text, emphasis</css-marker-patterns>
|
||||
</rules>
|
||||
|
||||
## See Also
|
||||
|
||||
- `blueprints-index.md` — the scene-shape templates (this skill's "blueprints") that compose these rules into full shots
|
||||
- `techniques.md` — broader motion-design techniques (SVG path drawing, Canvas 2D, CSS 3D, kinetic type, variable fonts, compositing); a few rules cite it
|
||||
- `transitions/` — scene-transition catalog (shared skill; story owns `transition_in`, the harness injects it)
|
||||
@@ -0,0 +1,227 @@
|
||||
---
|
||||
name: 3d-page-scroll
|
||||
description: Full webpage rendered as tilted 3D card that scrolls to reveal specific sections.
|
||||
metadata:
|
||||
tags: 3d, page, scroll, webpage, tilt, product-demo, perspective
|
||||
---
|
||||
|
||||
# 3D Page Scroll
|
||||
|
||||
A webpage (or long content) presented as a tilted 3D card. Spring-eased scroll reveals specific sections while the static 3D perspective adds physical depth.
|
||||
|
||||
## How It Works
|
||||
|
||||
Two independent transforms combine:
|
||||
|
||||
1. **3D tilt** — Static `rotateY` + `rotateX` with `perspective` on the card. The angle does **not** change during the scene.
|
||||
2. **Scroll** — The content inside the card translates vertically (`translateY` / `y` in GSAP) within a clipped container, driven by a GSAP tween. Spring-like deceleration via `ease: "power3.out"` or `"power4.out"`.
|
||||
|
||||
Optional layer:
|
||||
|
||||
3. **Spotlight overlay** — A radial-gradient mask dims everything except a focal region after the scroll lands. Use to draw attention to one section.
|
||||
|
||||
For multi-step scrolling (scroll → pause → scroll), use multiple `tl.to(".page-content", { y: -<distance>, ... }, <position>)` calls at different timeline positions.
|
||||
|
||||
## HTML
|
||||
|
||||
```html
|
||||
<div
|
||||
class="scene"
|
||||
id="page-scroll-scene"
|
||||
data-composition-id="page-scroll-scene"
|
||||
data-start="0"
|
||||
data-duration="5"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div class="tilt-card">
|
||||
<div class="page-content">
|
||||
<!-- Full {Brand} webpage recreation, taller than card height so
|
||||
scrolling matters. Each section is real DOM, not a screenshot. -->
|
||||
<section class="page-hero">{heroContents}</section>
|
||||
<section class="page-features">{featuresContents}</section>
|
||||
<section class="page-target" id="target-section">{targetContents}</section>
|
||||
<section class="page-cta">{ctaContents}</section>
|
||||
</div>
|
||||
|
||||
<div class="spotlight"></div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS (hero-frame layout)
|
||||
|
||||
```css
|
||||
.scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tilt-card {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
/* tilt + perspective set in CSS only if no other transform tween touches
|
||||
this element. If GSAP also tweens scale on .tilt-card, set the tilt
|
||||
via gsap.set() to avoid matrix overwrites. */
|
||||
transform: translate(-50%, -50%) perspective({perspectivePx}) rotateY({tiltYDeg}) rotateX({tiltXDeg});
|
||||
transform-style: preserve-3d;
|
||||
width: {cardWidth};
|
||||
height: {cardHeight};
|
||||
border-radius: 24px;
|
||||
background: {cardBackgroundColor};
|
||||
overflow: hidden; /* clip the scrolling content */
|
||||
/* shadow X-offset sign must match tiltY sign (negative tiltY ⇒ positive X) */
|
||||
box-shadow: 40px 30px 80px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.page-content {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
/* height is intrinsic from sections — taller than .tilt-card.height */
|
||||
}
|
||||
|
||||
.page-content section {
|
||||
height: {sectionHeight}; /* sections sized so cumulative offset = target distance */
|
||||
padding: 64px;
|
||||
/* section-specific styling … */
|
||||
}
|
||||
|
||||
.spotlight {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
background: radial-gradient(
|
||||
ellipse 60% 35% at 50% 50%,
|
||||
transparent 50%,
|
||||
{spotlightDimColor} 100%
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// Phase 1 — Card enters (optional, can skip if card is in from t=0)
|
||||
// Phase 2 — Scroll to the target section.
|
||||
// SCROLL_DISTANCE is measured at design time from the page layout
|
||||
// (top of .page-content origin to vertical center of #target-section,
|
||||
// accounting for card height).
|
||||
tl.to(
|
||||
".page-content",
|
||||
{
|
||||
y: -SCROLL_DISTANCE,
|
||||
duration: SCROLL_DUR,
|
||||
ease: "power3.out",
|
||||
},
|
||||
SCROLL_AT,
|
||||
);
|
||||
|
||||
// Phase 3 — Spotlight fades in on the target after scroll settles
|
||||
tl.to(
|
||||
".spotlight",
|
||||
{
|
||||
opacity: 1,
|
||||
duration: SPOTLIGHT_FADE_DUR,
|
||||
ease: "power1.inOut",
|
||||
},
|
||||
SPOTLIGHT_AT,
|
||||
);
|
||||
|
||||
window.__timelines["page-scroll-scene"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
### Multi-phase scroll variant
|
||||
|
||||
```js
|
||||
// Scroll to section A → hold → scroll to section B.
|
||||
// SCROLL_DISTANCE_A and SCROLL_DISTANCE_B are both measured from the
|
||||
// .page-content origin (NOT delta from previous step).
|
||||
tl.to(
|
||||
".page-content",
|
||||
{ y: -SCROLL_DISTANCE_A, duration: SCROLL_DUR, ease: "power3.out" },
|
||||
SCROLL_AT_A,
|
||||
);
|
||||
tl.to(
|
||||
".page-content",
|
||||
{ y: -SCROLL_DISTANCE_B, duration: SCROLL_DUR, ease: "power3.out" },
|
||||
SCROLL_AT_B,
|
||||
);
|
||||
```
|
||||
|
||||
GSAP composes successive `y:` tweens additively when targeting the same property — each tween starts from the value left by the previous tween.
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
- **tiltYDeg** — static Y rotation in CSS (or via `gsap.set()`).
|
||||
- Range: -12 to -4 (left-leaning) or 4 to 12 (right-leaning); 0 = no perspective rotation.
|
||||
- Effects: bigger magnitude = more dramatic 3D; near 0 collapses to a flat panel.
|
||||
- Constraints: shadow X-offset sign must match (negative tiltY ⇒ positive box-shadow X).
|
||||
- **tiltXDeg** — static X rotation.
|
||||
- Range: 0-6
|
||||
- Effects: positive tilts the top edge away from the viewer.
|
||||
- **perspectivePx** — perspective distance.
|
||||
- Range: 800-2000 px
|
||||
- Effects: smaller = more dramatic foreshortening; larger = nearly orthographic.
|
||||
- **cardWidth / cardHeight** — card frame size.
|
||||
- Constraints: card height < total content height, otherwise scroll has nothing to reveal.
|
||||
- **sectionHeight** — height of each scrolled section.
|
||||
- Constraints: sum of all section heights ≥ cardHeight + SCROLL_DISTANCE so the target section ends up within frame after scroll.
|
||||
- **SCROLL_AT** — timeline second at which the scroll tween begins.
|
||||
- Constraints: must be ≥ end of any prior fade-in tweens on `.page-content`.
|
||||
- **SCROLL_DUR** — duration of one scroll tween.
|
||||
- Range: 0.8-1.8 s
|
||||
- Effects: shorter feels like a hard cut; longer feels programmatic.
|
||||
- **SCROLL_DISTANCE** — pixels to translate `.page-content` upward.
|
||||
- Constraints: measured once at design time from the target section's offset; NOT a free tunable.
|
||||
- **SPOTLIGHT_AT** — timeline second at which the spotlight begins fading in.
|
||||
- Constraints: should be ≥ SCROLL_AT + SCROLL_DUR (or slightly earlier for overlapping handoff) so the spotlight reveals the freshly-arrived section.
|
||||
- **SPOTLIGHT_FADE_DUR** — spotlight opacity fade-in duration.
|
||||
- Range: 0.4-0.8 s
|
||||
- Multi-phase variant — **SCROLL_AT_A / SCROLL_AT_B**: must satisfy `SCROLL_AT_A + SCROLL_DUR ≤ SCROLL_AT_B` so the two scrolls don't fight for the y property.
|
||||
|
||||
Ease family — discrete choice:
|
||||
|
||||
- `power3.out` — heavy deceleration; reads as a programmatic scroll that "lands". Default.
|
||||
- `power4.out` — even heavier; reads as a momentum-driven scroll.
|
||||
- `power2.inOut` — symmetric; reads as a cinematic camera pan rather than UI scroll.
|
||||
|
||||
Pick one and use it across all scrolls in the scene — mixing easings within one scene reads as jerky.
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Tilt is static**, not animated. The card holds its angle the whole scene.
|
||||
- **Shadow direction matches tilt**: a left-leaning card casts shadow to the right (positive X shadow offset). Mismatch breaks the 3D illusion.
|
||||
- **Page content is real HTML**, not a screenshot. Screenshots can't be individually highlighted or scrolled-to with precision.
|
||||
- **Use real layout for distances**: scroll target distance comes from the actual cumulative section heights, not estimated pixel values.
|
||||
- **Spotlight as overlay**, not inside the page-content — overlay sits above scrolling content and stays fixed relative to the card.
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **`overflow: hidden` on `.tilt-card`** — scrolling content must clip at card boundaries, otherwise it leaks past the rounded corners
|
||||
- **`transform-style: preserve-3d`** on `.tilt-card` — required for any 3D children (or for combining `perspective` with rotations cleanly)
|
||||
- **Timeline must be paused**: `gsap.timeline({ paused: true })`. Never `tl.play()` — HF seeks frame-by-frame
|
||||
- **Registry key = `data-composition-id`**: `window.__timelines["page-scroll-scene"]` must match scene root's `data-composition-id`
|
||||
- **Finite scroll distance** — compute from actual content geometry; don't use arbitrary values that may overshoot the content end
|
||||
- **Same easing across multi-phase scroll** — mixing `power3.out` and `power1.inOut` looks jerky; pick one for the scene
|
||||
|
||||
## Combinations
|
||||
|
||||
- [asr-keyword-glow.md](asr-keyword-glow.md) — highlight elements on the page synced to voiceover word timestamps
|
||||
- [multi-phase-camera.md](multi-phase-camera.md) — overall camera zoom while the page scrolls (zoom-in to target section as it lands)
|
||||
- [cursor-click-ripple.md](cursor-click-ripple.md) — cursor lands on a UI element within the scrolled-into-view section
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — timeline + ease reference; `y:` tween basics
|
||||
- `/hyperframes-core` — composition wiring, `data-*` attributes
|
||||
- `/hyperframes-cli` — `hyperframes lint` to verify the registry key + duration
|
||||
@@ -0,0 +1,297 @@
|
||||
---
|
||||
name: 3d-text-depth-layers
|
||||
description: Multiple offset text layers create a stacked 3D shadow / extrusion effect on large typography — more impactful than CSS text-shadow because each layer is a full DOM element.
|
||||
metadata:
|
||||
tags: text, 3d, depth, layers, shadow, typography, stacked, extrusion
|
||||
---
|
||||
|
||||
# 3D Text Depth Layers
|
||||
|
||||
Renders the same text N times at increasing offsets, with back layers translucent and the front layer fully opaque. Creates a physical "stacked extrusion" depth illusion. Distinct from `text-shadow` (which can't have per-layer hue / opacity / animation) — each layer is a real DOM element.
|
||||
|
||||
## How It Works
|
||||
|
||||
- N copies of the same text in a single container
|
||||
- Each copy positioned absolutely with offset `(i * OFFSET_X, i * OFFSET_Y)`
|
||||
- Back layers (high `i`) use translucent or darkened color
|
||||
- Front layer (`i = 0`) is full opacity, full brand color
|
||||
- Optionally: each layer fades in staggered, creating a "building up" depth animation
|
||||
|
||||
## HTML
|
||||
|
||||
```html
|
||||
<div
|
||||
class="scene"
|
||||
id="depth-scene"
|
||||
data-composition-id="depth-scene"
|
||||
data-start="0"
|
||||
data-duration="3"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div class="depth-stack">
|
||||
<!-- Layers injected by script — LAYER_COUNT copies of {label} -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS
|
||||
|
||||
```css
|
||||
.scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: {bgColor};
|
||||
}
|
||||
.depth-stack {
|
||||
position: relative;
|
||||
/* Container size set by the front layer; back layers stack behind */
|
||||
}
|
||||
.depth-text {
|
||||
font-family: {font};
|
||||
font-weight: 900;
|
||||
font-size: HERO_FONT_SIZE;
|
||||
letter-spacing: HERO_LETTER_SPACING;
|
||||
line-height: 1;
|
||||
color: {frontColor};
|
||||
text-transform: uppercase;
|
||||
}
|
||||
/* Back layers — absolute, stacked behind */
|
||||
.depth-text.is-back {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Front layer — relative to define container size */
|
||||
.depth-text.is-front {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline + Layer Setup
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
|
||||
const TEXT = "{label}";
|
||||
const stack = document.querySelector(".depth-stack");
|
||||
|
||||
// Build layers — back-to-front so the FRONT (i=0) is the LAST appended
|
||||
// and `position: relative` defines container size.
|
||||
for (let i = LAYER_COUNT - 1; i >= 0; i--) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "depth-text " + (i === 0 ? "is-front" : "is-back");
|
||||
el.textContent = TEXT;
|
||||
if (i > 0) {
|
||||
const alpha = Math.max(BACK_ALPHA_MAX - i * BACK_ALPHA_STEP, BACK_ALPHA_MIN);
|
||||
el.style.color = `rgba({backHueRGB}, ${alpha})`;
|
||||
el.style.transform = `translate(${i * OFFSET_X}px, ${i * OFFSET_Y}px)`;
|
||||
} else {
|
||||
el.style.color = "{frontColor}";
|
||||
}
|
||||
el.dataset.layer = String(i);
|
||||
stack.appendChild(el);
|
||||
}
|
||||
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// Layered entry — back layers appear first, building forward
|
||||
const allLayers = stack.querySelectorAll(".depth-text");
|
||||
allLayers.forEach((el) => {
|
||||
const i = Number(el.dataset.layer);
|
||||
const finalAlpha = el.classList.contains("is-front")
|
||||
? 1
|
||||
: Math.max(BACK_ALPHA_MAX - i * BACK_ALPHA_STEP, BACK_ALPHA_MIN);
|
||||
tl.fromTo(
|
||||
el,
|
||||
{ opacity: 0 },
|
||||
{
|
||||
opacity: finalAlpha,
|
||||
duration: LAYER_FADE_DUR,
|
||||
ease: "power2.out",
|
||||
},
|
||||
LAYER_CASCADE_START + (LAYER_COUNT - 1 - i) * LAYER_CASCADE_STEP,
|
||||
);
|
||||
});
|
||||
|
||||
// Optional: depth grows on entry (offset interpolates from 0 → full)
|
||||
const depthState = { p: 0 };
|
||||
tl.to(
|
||||
depthState,
|
||||
{
|
||||
p: 1,
|
||||
duration: DEPTH_GROW_DUR,
|
||||
ease: "power2.out",
|
||||
onUpdate: () => {
|
||||
stack.querySelectorAll(".depth-text.is-back").forEach((el) => {
|
||||
const i = Number(el.dataset.layer);
|
||||
const x = i * OFFSET_X * depthState.p;
|
||||
const y = i * OFFSET_Y * depthState.p;
|
||||
el.style.transform = `translate(${x}px, ${y}px)`;
|
||||
});
|
||||
},
|
||||
},
|
||||
DEPTH_GROW_START,
|
||||
);
|
||||
|
||||
window.__timelines["depth-scene"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
### Static depth (no animation, single hero shot)
|
||||
|
||||
Skip the cascade — render all layers in their final positions from t=0, optionally fade the entire stack in:
|
||||
|
||||
```js
|
||||
tl.from(
|
||||
stack,
|
||||
{ opacity: 0, scale: STATIC_ENTRY_SCALE, duration: STATIC_ENTRY_DUR, ease: "power3.out" },
|
||||
0,
|
||||
);
|
||||
```
|
||||
|
||||
### Dynamic depth pulse
|
||||
|
||||
Animate `OFFSET_X` / `OFFSET_Y` based on a heartbeat — depth grows and shrinks rhythmically:
|
||||
|
||||
```js
|
||||
const beat = { p: 0 };
|
||||
tl.to(
|
||||
beat,
|
||||
{
|
||||
p: Math.PI * 2 * BEAT_CYCLES,
|
||||
duration: BEAT_DUR,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
const mult = 1 + Math.sin(beat.p) * BEAT_AMP;
|
||||
stack.querySelectorAll(".is-back").forEach((el) => {
|
||||
const i = Number(el.dataset.layer);
|
||||
el.style.transform = `translate(${i * OFFSET_X * mult}px, ${i * OFFSET_Y * mult}px)`;
|
||||
});
|
||||
},
|
||||
},
|
||||
BEAT_START,
|
||||
);
|
||||
```
|
||||
|
||||
### Color-shift back layers
|
||||
|
||||
Instead of fading to translucent, shift to a different hue — depth reads as "casting a colored shadow":
|
||||
|
||||
```js
|
||||
el.style.color = `hsla(${HUE_BASE - i * HUE_STEP}, ${SAT_PCT}%, ${LIGHT_BASE - i * LIGHT_STEP}%, 1)`;
|
||||
```
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
### Layer geometry
|
||||
|
||||
- **LAYER_COUNT** — number of stacked copies (back layers + 1 front).
|
||||
- Range: 4-6. Below 4 the depth doesn't read as 3D; above 6 the stack visually clutters on tight kerning
|
||||
- Effects: low end reads as subtle shadow; high end reads as chunky extrusion
|
||||
- **OFFSET_X / OFFSET_Y** — per-layer translation offset, in px.
|
||||
- Range: 1-3 px each. Above 4 px reads as a glitch / chromatic aberration rather than depth
|
||||
- Effects: offset direction implies light direction. `(+x, +y)` = light from upper-left; `(-x, +y)` = light from upper-right. Pick one and keep it consistent across the composition
|
||||
- Constraints: same sign convention throughout the composition
|
||||
|
||||
### Back-layer color falloff
|
||||
|
||||
- **BACK_ALPHA_MAX** — alpha of the back layer nearest the front.
|
||||
- Range: 0.6-0.85. Lower than 0.5 makes even the nearest back layer disappear; higher than 0.9 fights the front layer for dominance
|
||||
- **BACK_ALPHA_STEP** — alpha decrement per layer further back.
|
||||
- Range: 0.08-0.15. Smaller steps read as a soft gradient; larger steps read as discrete plates
|
||||
- Constraints: choose so `BACK_ALPHA_MAX − (LAYER_COUNT − 2) × BACK_ALPHA_STEP ≥ BACK_ALPHA_MIN`
|
||||
- **BACK_ALPHA_MIN** — floor below which back layers stop fading.
|
||||
- Range: 0.1-0.2. Below 0.1 the deepest layer disappears entirely on dark backgrounds
|
||||
|
||||
### Typography
|
||||
|
||||
- **HERO_FONT_SIZE** — front-layer font size, in px.
|
||||
- Range: 60 px minimum to read as layered; 200-340 px for full-bleed hero shots. Thin text loses the layered illusion
|
||||
- **HERO_LETTER_SPACING** — letter spacing.
|
||||
- Range: −0.03em (tight) to 0 (normal). Negative spacing tightens the stack so the offsets read as depth instead of as repetition
|
||||
- **{font}** — typeface; pick a black/900 weight family with strong horizontal strokes
|
||||
- **{frontColor}** — front-layer color; the brand or accent color
|
||||
- **{backHueRGB}** — RGB triplet for back layers (e.g. matched to a brand glow). Used inside `rgba({backHueRGB}, ${alpha})`
|
||||
|
||||
### Cascade entry (default form)
|
||||
|
||||
- **LAYER_CASCADE_START** — timeline offset where the cascade begins.
|
||||
- Constraints: ≥ 0; if another beat precedes, ≥ that beat's end
|
||||
- **LAYER_CASCADE_STEP** — delay between each layer's fade-in.
|
||||
- Range: 0.04-0.10 s. Smaller feels almost-simultaneous; larger feels stepped and mechanical
|
||||
- **LAYER_FADE_DUR** — duration of each individual layer's fade-in.
|
||||
- Range: 0.3-0.6 s
|
||||
|
||||
### Depth-grow tween
|
||||
|
||||
- **DEPTH_GROW_START** — when the offset growth begins.
|
||||
- Constraints: typically `≈ LAYER_CASCADE_START`; align so the first layer's fade and the depth growth start together
|
||||
- **DEPTH_GROW_DUR** — duration over which offsets interpolate from 0 to full.
|
||||
- Range: 0.4-0.8 s. Roughly match `LAYER_FADE_DUR × LAYER_COUNT / 2` so depth lands as the last layer fades in
|
||||
|
||||
### Static-depth variation
|
||||
|
||||
- **STATIC_ENTRY_SCALE** — initial scale before the whole stack fades in.
|
||||
- Range: 0.94-0.98 — subtle inflation; larger reads as a separate "pop" effect
|
||||
- **STATIC_ENTRY_DUR** — fade-in duration for the whole stack.
|
||||
- Range: 0.5-0.8 s
|
||||
|
||||
### Dynamic-pulse variation
|
||||
|
||||
- **BEAT_CYCLES** — number of full beat cycles across `BEAT_DUR`.
|
||||
- Range: `BEAT_DUR / 1.5s ≤ BEAT_CYCLES ≤ BEAT_DUR / 0.7s` (one beat per 0.7-1.5 s reads as a heartbeat)
|
||||
- **BEAT_DUR** — pulse tween duration.
|
||||
- Constraints: tied to the visible window of the depth stack
|
||||
- **BEAT_AMP** — fractional amplitude of the offset pulse.
|
||||
- Range: 0.2-0.6. Smaller is a gentle breathing depth; larger reads as a kick-drum thump
|
||||
- **BEAT_START** — when the pulse begins.
|
||||
- Constraints: `≥ DEPTH_GROW_START + DEPTH_GROW_DUR` so the pulse modulates a fully-grown stack
|
||||
|
||||
### Color-shift variation
|
||||
|
||||
- **HUE_BASE / HUE_STEP** — base hue (front layer) and per-layer hue rotation.
|
||||
- Range: `HUE_STEP` 4-12°. Larger steps cycle further around the color wheel and read as glitch
|
||||
- **SAT_PCT** — fixed saturation for all layers.
|
||||
- Range: 60-85%
|
||||
- **LIGHT_BASE / LIGHT_STEP** — base lightness and per-layer darkening.
|
||||
- Range: `LIGHT_STEP` 3-8 percentage points so back layers darken into the background
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Layer count 4-6** — fewer than 4 doesn't read as 3D, more than 6 visually clutters on tight kerning
|
||||
- **Offset 1-3 px per axis** — subtle is dramatic. `OFFSET = 6+` looks like a glitch rather than depth
|
||||
- **Offset direction implies light direction** — `(+x, +y)` = light from upper-left; `(-x, +y)` = light from upper-right. Pick one and be consistent across the composition
|
||||
- **Back layers translucent OR darker** — DON'T make them MORE saturated than the front (looks like a halo). Each back layer should be slightly more transparent (`alpha -= BACK_ALPHA_STEP per layer`) or slightly darker
|
||||
- **Last (front) layer `position: relative`** to define container size; all others `position: absolute` stack behind
|
||||
- **Bold/black weight + large size** — 900 weight, 60 px+ minimum. Thin text loses the layered illusion
|
||||
- **Don't apply per-letter animation on top of layers** — character animations (hacker-flip, typewriter) on top of 6-layer depth = chaos. If you need both effects, drop depth to 2-3 layers OR apply layers only to the static post-reveal state
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Timeline must be paused**: `gsap.timeline({ paused: true })`
|
||||
- **Registry key = `data-composition-id`**
|
||||
- **No CSS `text-shadow`** alongside layered depth — they compound and over-extrude
|
||||
- **Use `transform: translate()` for offsets, not `top`/`left`** — translate composes cleanly with parent's centering and avoids reflow
|
||||
- **`pointer-events: none` on back layers** — they're decorative; don't catch hover or selection
|
||||
- **Set layer color via `rgba()` not opacity** — opacity on the whole element fades the rendered glyph including any shadow; rgba in `color` fades just the glyph
|
||||
|
||||
## Combinations
|
||||
|
||||
- [counting-dynamic-scale.md](counting-dynamic-scale.md) — render the counter number with depth layers
|
||||
- [sine-wave-loop.md](sine-wave-loop.md) — idle breathing on the front layer after reveal
|
||||
- [center-outward-expansion.md](center-outward-expansion.md) — depth-stacked wordmark reveals after burst lands
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — staggered fade-ins + onUpdate for dynamic depth
|
||||
- `/hyperframes-core` — composition wiring
|
||||
- `/hyperframes-cli` — `hyperframes lint`
|
||||
@@ -0,0 +1,382 @@
|
||||
---
|
||||
name: ai-tracking-box
|
||||
description: Animated bounding box with L-shaped corner markers following an oscillating path — simulates AI object detection / tracking.
|
||||
metadata:
|
||||
tags: ai, tracking, bounding-box, detection, corner, yellow, ml
|
||||
---
|
||||
|
||||
# AI Tracking Box
|
||||
|
||||
A bounding box with corner markers ("L-brackets") that follows a moving target, simulating real-time AI detection. Position and size oscillate on sine paths to mimic continuous re-computation. Conventionally rendered in "AI detection yellow" (`{detectionYellow}`) on a dark background with a confidence label.
|
||||
|
||||
## How It Works
|
||||
|
||||
- Box position `(x, y)` and size `(w, h)` are derived from sine + drift across composition time
|
||||
- 4 L-bracket corner markers (`<div>` per corner with two-sided borders) sit ON the box
|
||||
- Optional label tag above the top-left corner showing class name + confidence percent
|
||||
|
||||
All driven by GSAP timeline so HF seeks deterministically.
|
||||
|
||||
## HTML
|
||||
|
||||
```html
|
||||
<div
|
||||
class="scene"
|
||||
id="track-scene"
|
||||
data-composition-id="track-scene"
|
||||
data-start="0"
|
||||
data-duration="5"
|
||||
data-track-index="0"
|
||||
>
|
||||
<!-- Background — could be a product mockup, hero image, etc. -->
|
||||
<div class="bg">
|
||||
<div class="bg-content">{Brand}</div>
|
||||
<div class="bg-mascot" id="mascot">{targetGlyph}</div>
|
||||
</div>
|
||||
|
||||
<!-- Tracking box wraps the target -->
|
||||
<div class="track-box" id="track-box">
|
||||
<div class="corner tl"></div>
|
||||
<div class="corner tr"></div>
|
||||
<div class="corner bl"></div>
|
||||
<div class="corner br"></div>
|
||||
<div class="label" id="label">{targetGlyph} {LABEL} · {confidence}%</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS
|
||||
|
||||
```css
|
||||
.scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: radial-gradient(ellipse at center, {bgInner} 0%, {bgOuter} 70%);
|
||||
font-family: {font};
|
||||
overflow: hidden;
|
||||
}
|
||||
.bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 60px;
|
||||
}
|
||||
.bg-content {
|
||||
position: absolute;
|
||||
top: 120px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 80px;
|
||||
font-weight: 900;
|
||||
color: {bgTextColor};
|
||||
letter-spacing: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.bg-mascot {
|
||||
position: absolute;
|
||||
font-size: 240px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.track-box {
|
||||
position: absolute;
|
||||
/* Position + size set by GSAP onUpdate */
|
||||
pointer-events: none;
|
||||
will-change: transform, width, height;
|
||||
}
|
||||
.corner {
|
||||
position: absolute;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
.corner.tl {
|
||||
top: -8px;
|
||||
left: -8px;
|
||||
border-top: 6px solid {detectionYellow};
|
||||
border-left: 6px solid {detectionYellow};
|
||||
}
|
||||
.corner.tr {
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
border-top: 6px solid {detectionYellow};
|
||||
border-right: 6px solid {detectionYellow};
|
||||
}
|
||||
.corner.bl {
|
||||
bottom: -8px;
|
||||
left: -8px;
|
||||
border-bottom: 6px solid {detectionYellow};
|
||||
border-left: 6px solid {detectionYellow};
|
||||
}
|
||||
.corner.br {
|
||||
bottom: -8px;
|
||||
right: -8px;
|
||||
border-bottom: 6px solid {detectionYellow};
|
||||
border-right: 6px solid {detectionYellow};
|
||||
}
|
||||
.label {
|
||||
position: absolute;
|
||||
top: -56px;
|
||||
left: -8px;
|
||||
padding: 8px 16px;
|
||||
background: {detectionYellow};
|
||||
color: {labelTextColor};
|
||||
font-family: {monoFont};
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 2px;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
const box = document.getElementById("track-box");
|
||||
const mascot = document.getElementById("mascot");
|
||||
const label = document.getElementById("label");
|
||||
|
||||
// Initial state — box invisible, will fade in
|
||||
gsap.set(box, { opacity: 0, scale: ENTRY_SCALE });
|
||||
|
||||
// Phase 1 — box entry (fade in + scale to 1)
|
||||
tl.to(
|
||||
box,
|
||||
{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
duration: ENTRY_DUR,
|
||||
ease: `back.out(${ENTRY_BOUNCE})`,
|
||||
},
|
||||
ENTRY_START,
|
||||
);
|
||||
|
||||
// Phase 2 — continuous "AI tracking" — box and mascot move in lock-step on sine paths
|
||||
const SCREEN_CENTER = { x: COMP_WIDTH / 2, y: COMP_HEIGHT / 2 };
|
||||
// DRIFT_X / DRIFT_Y — target oscillation amplitude (px)
|
||||
// SIZE_BASE / SIZE_VAR — box mean size + per-frame jitter amplitude (px)
|
||||
// SIZE_FREQ_MULT — multiplier on tracking phase so box "breathes" off-tempo from drift
|
||||
// CYCLES — number of full oscillations across TRACK_DUR
|
||||
// TRACK_DUR — duration of the tracking phase (s)
|
||||
// TRACK_START — when tracking phase begins (s)
|
||||
// CONFIDENCE_MEAN / CONFIDENCE_VAR — center + half-range of the flickering confidence %
|
||||
// CONFIDENCE_FREQ_MULT — how fast confidence flickers relative to drift
|
||||
|
||||
const tracking = { p: 0 };
|
||||
tl.to(
|
||||
tracking,
|
||||
{
|
||||
p: Math.PI * 2 * CYCLES,
|
||||
duration: TRACK_DUR,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
// Target position (the mascot moves on a wider arc)
|
||||
const mx = SCREEN_CENTER.x + Math.cos(tracking.p) * DRIFT_X;
|
||||
const my = SCREEN_CENTER.y + Math.sin(tracking.p) * DRIFT_Y;
|
||||
mascot.style.position = "absolute";
|
||||
mascot.style.left = `${mx - MASCOT_SIZE / 2}px`;
|
||||
mascot.style.top = `${my - MASCOT_SIZE / 2}px`;
|
||||
|
||||
// Box size oscillates slightly (size confidence variation)
|
||||
const w = SIZE_BASE + Math.sin(tracking.p * SIZE_FREQ_MULT) * SIZE_VAR;
|
||||
const h = SIZE_BASE + Math.sin(tracking.p * SIZE_FREQ_MULT + Math.PI / 2) * SIZE_VAR;
|
||||
|
||||
// Box position centers on mascot
|
||||
box.style.width = `${w}px`;
|
||||
box.style.height = `${h}px`;
|
||||
box.style.left = `${mx - w / 2}px`;
|
||||
box.style.top = `${my - h / 2}px`;
|
||||
|
||||
// Confidence label fluctuates inside [CONFIDENCE_MEAN ± CONFIDENCE_VAR]
|
||||
const confidence = Math.round(
|
||||
CONFIDENCE_MEAN + Math.sin(tracking.p * CONFIDENCE_FREQ_MULT) * CONFIDENCE_VAR,
|
||||
);
|
||||
label.textContent = `${TARGET_GLYPH} ${LABEL_TEXT} · ${confidence}%`;
|
||||
},
|
||||
},
|
||||
TRACK_START,
|
||||
);
|
||||
|
||||
window.__timelines["track-scene"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
- **ENTRY_SCALE** — start scale of the box before it pops in
|
||||
- Range: 0.5-0.9
|
||||
- Effects: low end = stronger pop / more "snapping into focus"; high end = subtle reveal
|
||||
- Constraints: must be < 1 (box scales UP into place)
|
||||
- Reference: examples use 0.7
|
||||
|
||||
- **ENTRY_DUR** — duration of the fade-in + scale-up (seconds)
|
||||
- Range: 0.3-0.8 s
|
||||
- Effects: low end = snappy / authoritative lock-on; high end = soft / observational
|
||||
- Constraints: should end before TRACK_START
|
||||
- Reference: examples use 0.5
|
||||
|
||||
- **ENTRY_START** — when the entry tween begins (seconds, absolute on timeline)
|
||||
- Range: 0-2 s typically
|
||||
- Effects: late start = lets the viewer notice the target first before the AI "finds" it; early start = AI is already watching
|
||||
- Constraints: ENTRY_START + ENTRY_DUR ≤ TRACK_START
|
||||
- Reference: examples use 0.5
|
||||
|
||||
- **ENTRY_BOUNCE** — coefficient passed to `back.out(...)` on entry
|
||||
- Range: 1.2-2.5
|
||||
- Effects: low end = subtle overshoot; high end = exaggerated snap (reads as "aggressive lock-on")
|
||||
- Constraints: stick to `back.out` family — `elastic` reads as cartoonish, `power` reads as flat
|
||||
- Reference: examples use 1.4
|
||||
|
||||
- **TRACK_START** — when continuous tracking begins (seconds)
|
||||
- Range: ≥ ENTRY_START + ENTRY_DUR
|
||||
- Effects: gap between entry and tracking = pause for emphasis; no gap = seamless lock + follow
|
||||
- Constraints: TRACK_START + TRACK_DUR ≤ composition duration
|
||||
- Reference: examples use 1.0
|
||||
|
||||
- **TRACK_DUR** — length of the tracking phase (seconds)
|
||||
- Range: 2-8 s
|
||||
- Effects: short = "quick scan"; long = "sustained observation"
|
||||
- Constraints: must accommodate at least one full CYCLE to read as oscillation
|
||||
- Reference: examples use 4.0
|
||||
|
||||
- **CYCLES** — number of full sine oscillations of the target across TRACK_DUR
|
||||
- Range: 0.5-3
|
||||
- Effects: low = lazy drift; high = jittery / hyperactive target
|
||||
- Constraints: CYCLES / TRACK_DUR sets effective Hz of drift; keep < ~0.6 Hz or motion blurs
|
||||
- Reference: examples use 1.5
|
||||
|
||||
- **DRIFT_X / DRIFT_Y** — amplitude of target oscillation around screen center (px, 1920×1080 basis)
|
||||
- Range: 40-200 px
|
||||
- Effects: small = subtle hover; large = wide chase that pushes the box near the frame edge
|
||||
- Constraints: SCREEN_CENTER ± DRIFT must keep mascot fully on screen given MASCOT_SIZE
|
||||
- Reference: examples use 80 / 50
|
||||
|
||||
- **SIZE_BASE** — mean width/height of the bounding box (px)
|
||||
- Range: 200-500 px
|
||||
- Effects: small = "specimen tag"; large = "the box IS the subject"
|
||||
- Constraints: must visibly enclose the target glyph at all confidence sizes
|
||||
- Reference: examples use 320
|
||||
|
||||
- **SIZE_VAR** — half-amplitude of per-frame size jitter (px)
|
||||
- Range: 5-10% of SIZE_BASE
|
||||
- Effects: low end = stable / confident detector; high end = jittery / re-fitting detector. Outside this range reads as "broken" (too much) or "static UI" (none)
|
||||
- Constraints: keep < 0.15 × SIZE_BASE to avoid breaking the L-bracket illusion
|
||||
- Reference: examples use 30 (~9% of 320)
|
||||
|
||||
- **SIZE_FREQ_MULT** — multiplier on tracking phase for size oscillation
|
||||
- Range: 1.5-3
|
||||
- Effects: 1 = size pulses in lock-step with drift (reads mechanical); irrational ratio = organic re-fitting
|
||||
- Constraints: avoid integer ratios; non-integer reads as continuous recomputation
|
||||
- Reference: examples use 2.3
|
||||
|
||||
- **MASCOT_SIZE** — rendered width of the mascot element (px); used to center it on (mx, my)
|
||||
- Range: matches CSS `font-size` of `.bg-mascot`
|
||||
- Effects: must match the actual rendered size or mascot drifts out of the box
|
||||
- Constraints: MASCOT_SIZE / 2 = offset applied to top/left
|
||||
- Reference: examples use 240 (matches `font-size: 240px`)
|
||||
|
||||
- **CONFIDENCE_MEAN** — center % shown on the label
|
||||
- Range: 95-99
|
||||
- Effects: < 95 reads "uncertain"; 100 reads "fake-precise". 97 is the sweet spot for "confident AI"
|
||||
- Constraints: keep CONFIDENCE_MEAN + CONFIDENCE_VAR ≤ 99
|
||||
- Reference: examples use 97
|
||||
|
||||
- **CONFIDENCE_VAR** — flicker half-range around CONFIDENCE_MEAN
|
||||
- Range: 1-3
|
||||
- Effects: 0 = static (looks like a screenshot); >3 = unstable (looks broken)
|
||||
- Constraints: CONFIDENCE_MEAN ± CONFIDENCE_VAR ⊂ [95, 99]
|
||||
- Reference: examples use 2
|
||||
|
||||
- **CONFIDENCE_FREQ_MULT** — multiplier on tracking phase for label flicker
|
||||
- Range: 3-6
|
||||
- Effects: low = synced with drift (mechanical); high = fast nervous flicker (reads "live inference")
|
||||
- Constraints: keep above SIZE_FREQ_MULT so label flickers faster than the box breathes
|
||||
- Reference: examples use 4
|
||||
|
||||
- **COMP_WIDTH / COMP_HEIGHT** — composition pixel dimensions (used to derive SCREEN_CENTER)
|
||||
- Range: dictated by the HF composition (`data-width` / `data-height`)
|
||||
- Effects: not a creative choice — match the parent composition
|
||||
- Constraints: SCREEN_CENTER = (COMP_WIDTH/2, COMP_HEIGHT/2)
|
||||
- Reference: examples use 1920 × 1080
|
||||
|
||||
- **{detectionYellow}** — corner-marker + label background color
|
||||
- This is a **discrete convention**, not a tunable range. AI detection overlays are yellow on dark backgrounds across the industry (autonomous-vehicle HUDs, security CV, ML demos). Red reads as "warning", green as "success", blue as "info" — none read as "detection."
|
||||
- Recommended: a saturated warm yellow (`#facc15` / `#FCD34D` family) on a dark navy or near-black background. Substituting any other hue loses genre legibility.
|
||||
- Reference: examples use `#facc15` (Tailwind `yellow-400`)
|
||||
|
||||
- **{bgInner} / {bgOuter}** — radial-gradient background stops
|
||||
- Should be dark and low-chroma so the yellow markers pop
|
||||
- Constraints: choose colors with sufficient contrast against {detectionYellow} (the corners and label must remain readable)
|
||||
- Reference: examples use `#161a3a` (inner) → `#0b0d1f` (outer)
|
||||
|
||||
- **{labelTextColor}** — text color inside the yellow label tag
|
||||
- Constraints: must contrast against {detectionYellow}; typically the same near-black as {bgOuter}
|
||||
- Reference: examples use `#0b0d1f`
|
||||
|
||||
- **{font} / {monoFont}** — scene text font and label font
|
||||
- {font}: sans-serif body font for {Brand} backdrop
|
||||
- {monoFont}: monospaced font for the confidence label (mono reinforces "machine readout" affordance)
|
||||
- Reference: examples use `"Inter", sans-serif` and `"JetBrains Mono", monospace`
|
||||
|
||||
## Variations
|
||||
|
||||
### Multi-object detection
|
||||
|
||||
Multiple boxes at different phases (each tracking its own mascot). Each is its own onUpdate-driven set; offset their phase by `Math.PI / N` so they don't tick synchronously.
|
||||
|
||||
### Lost-then-reacquired
|
||||
|
||||
The box fades to {LOST_OPACITY} (~{LOST_DUR}) then re-snaps to a new position with a "REACQUIRED" label flash:
|
||||
|
||||
```js
|
||||
tl.to(box, { opacity: LOST_OPACITY, duration: LOST_DUR }, LOST_START);
|
||||
tl.to(
|
||||
box,
|
||||
{ opacity: 1.0, duration: REACQUIRE_DUR, ease: `back.out(${REACQUIRE_BOUNCE})` },
|
||||
REACQUIRE_START,
|
||||
);
|
||||
tl.to(label, { textContent: "REACQUIRED · 99%", duration: 0 }, REACQUIRE_START);
|
||||
```
|
||||
|
||||
(LOST_OPACITY ≈ 0.2-0.4; REACQUIRE_BOUNCE ≈ 1.8-2.5 for a snappier re-lock than the initial entry.)
|
||||
|
||||
### Tracking-then-zoom
|
||||
|
||||
After tracking, the camera (via [viewport-change](viewport-change.md)) zooms into the tracked box. Combined effect: "the AI found something, now show it."
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Yellow on dark background is the detection convention** — see {detectionYellow} entry. Other colors lose the genre signal.
|
||||
- **Box ALWAYS contains the target** — recompute box position EVERY frame from target position; never trail behind. If the box lags, it reads as "broken tracker," not "smart AI."
|
||||
- **Subtle size variation (~5-10% of SIZE_BASE)** — too much and the tracker looks confused; just right reads as "real-time recomputation."
|
||||
- **Corner markers, not full borders** — L-brackets are the genre signature. Full border looks like a generic UI box.
|
||||
- **Confidence label flickers in a tight range (CONFIDENCE_MEAN ± CONFIDENCE_VAR inside [95, 99])** — outside that range reads as "uncertain"; ≥100 reads as "fake-precise."
|
||||
- **No CSS animation for the tracking — use timeline onUpdate** — HF seek-by-frame doesn't sync with CSS animation.
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Timeline must be paused**: `gsap.timeline({ paused: true })`
|
||||
- **Registry key = `data-composition-id`**
|
||||
- **No CSS animation on `.track-box` or `.corner`** — must be timeline-driven
|
||||
- **`will-change: transform, width, height`** on `.track-box`
|
||||
- **`pointer-events: none`** on `.track-box` — decorative overlay
|
||||
- **Box position recomputed per-frame from target** — never tween box position separately from target
|
||||
|
||||
## Combinations
|
||||
|
||||
- [viewport-change.md](viewport-change.md) — zoom into the tracked box after detection phase
|
||||
- [multi-phase-camera.md](multi-phase-camera.md) — wide shot during tracking, push-in on lock
|
||||
- [sine-wave-loop.md](sine-wave-loop.md) — the mascot itself idle-breathes inside the box
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — onUpdate writing multi-element positions
|
||||
- `/hyperframes-core` — composition wiring
|
||||
- `/hyperframes-cli` — `hyperframes lint`
|
||||
@@ -0,0 +1,305 @@
|
||||
---
|
||||
name: ambient-glow-bloom
|
||||
description: Un-triggered soft radial glow that blooms in behind a hero element and holds with a bounded idle breathe, or a single-pass traveling sweep across a surface. No click, no word-sync — it just blooms. Finite, deterministic, seek-safe.
|
||||
metadata:
|
||||
tags: glow, bloom, ambient, radial, sweep, hero, presence, finite, un-triggered
|
||||
---
|
||||
|
||||
# Ambient Glow Bloom
|
||||
|
||||
A soft radial glow that **blooms in behind a hero element** (card, logo, metric) and holds, giving it presence. Unlike `press-release-spring`'s click-triggered burst or `asr-keyword-glow`'s word-timed envelope, this glow is **un-triggered** — it simply blooms on the hero's settle and stays lit. Two forms: a **hero bloom** that swells behind a settling element and then breathes, and a **traveling glow sweep** that translates a soft highlight across a surface exactly once. Both are finite, deterministic, and seek-safe.
|
||||
|
||||
## How It Works
|
||||
|
||||
A radial-gradient layer sits **behind** the hero (`z-index` below it), starting at `opacity: 0`. Over the bloom-in window it ramps `opacity: 0 → peak` with a gentle `scale` swell (the halo "inflates" into place), timed to land on the hero's settle so the two read as one beat.
|
||||
|
||||
Two forms diverge after bloom-in:
|
||||
|
||||
1. **Hero bloom** — once lit, the glow does a **bounded idle breathe** during the hold. Drive it with an `onUpdate` reading `tl.time()` (NOT a `repeat: -1` yoyo): a `Math.sin` of elapsed time nudges `opacity` and `scale` a hair around their peak. At `sin(0) = 0` the breathe starts exactly at the bloom's resting state — no jump.
|
||||
2. **Traveling sweep** — a narrow highlight gradient at one edge of the surface translates **once** across to the other edge (`x` from off-surface to off-surface), a single finite pass. No loop, no return. The sweep layer is clipped to the surface so the highlight only reads where it overlaps.
|
||||
|
||||
Peak opacity stays restrained (≤ ~0.45) so the glow gives presence without washing the frame; the glow color is darker / more saturated than the element it backs.
|
||||
|
||||
## HTML
|
||||
|
||||
```html
|
||||
<div
|
||||
class="scene"
|
||||
id="bloom-scene"
|
||||
data-composition-id="bloom-scene"
|
||||
data-start="0"
|
||||
data-duration="DURATION"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div class="bloom-stage">
|
||||
<!-- Glow layer sits BEHIND the hero -->
|
||||
<div class="bloom-glow" id="bloom-glow"></div>
|
||||
<div class="hero-card" id="hero-card">{HeroLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
For the traveling-sweep form, the sweep layer is clipped to the surface it crosses:
|
||||
|
||||
```html
|
||||
<div class="surface" id="surface">
|
||||
<!-- grid / wordmark / card content here -->
|
||||
<div class="sweep" id="sweep"></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS
|
||||
|
||||
```css
|
||||
.scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: {bgColor};
|
||||
}
|
||||
.bloom-stage {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.hero-card {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: HERO_WIDTH;
|
||||
height: HERO_HEIGHT;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: {heroBg};
|
||||
border-radius: HERO_RADIUS;
|
||||
font-family: {font};
|
||||
font-weight: 900;
|
||||
font-size: HERO_FONT_SIZE;
|
||||
color: {heroTextColor};
|
||||
}
|
||||
.bloom-glow {
|
||||
/* Radial halo behind the hero — extends past it via negative inset */
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset: GLOW_INSET;
|
||||
background: {glowGradient};
|
||||
opacity: 0;
|
||||
transform: scale(GLOW_START_SCALE);
|
||||
transform-origin: 50% 50%;
|
||||
pointer-events: none;
|
||||
/* will-change because opacity + scale both animate during bloom AND breathe */
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* Traveling-sweep form */
|
||||
.surface {
|
||||
position: relative;
|
||||
overflow: hidden; /* clips the sweep to the surface footprint */
|
||||
border-radius: SURFACE_RADIUS;
|
||||
}
|
||||
.sweep {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
/* A narrow soft band, wider than it needs to be so the falloff is gentle */
|
||||
width: SWEEP_WIDTH;
|
||||
/* Diagonal highlight: angle the gradient so the sweep reads as raked light */
|
||||
background: {sweepGradient};
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
const glow = document.getElementById("bloom-glow");
|
||||
|
||||
// ── Form A: HERO BLOOM ──────────────────────────────────────────────
|
||||
// Phase 1 — bloom in. opacity 0 → peak + gentle scale swell, landing on
|
||||
// the hero's settle. ease "power2.out" so it arrives soft, not snappy.
|
||||
tl.fromTo(
|
||||
glow,
|
||||
{ opacity: 0, scale: GLOW_START_SCALE },
|
||||
{
|
||||
opacity: GLOW_PEAK_OPACITY,
|
||||
scale: 1,
|
||||
duration: BLOOM_DUR,
|
||||
ease: "power2.out",
|
||||
},
|
||||
BLOOM_START,
|
||||
);
|
||||
|
||||
// Phase 2 — bounded idle breathe during the hold. NOT a yoyo loop:
|
||||
// a single finite tween advances `phase` 0 → 2π·CYCLES; onUpdate reads it
|
||||
// and nudges opacity + scale a hair around their peak.
|
||||
// sin(0) = 0 → breathe starts exactly at the bloom's resting state.
|
||||
const phase = { p: 0 };
|
||||
tl.to(
|
||||
phase,
|
||||
{
|
||||
p: Math.PI * 2 * BREATHE_CYCLES,
|
||||
duration: BREATHE_DUR,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
const s = Math.sin(phase.p);
|
||||
glow.style.opacity = String(GLOW_PEAK_OPACITY + s * OPACITY_AMP);
|
||||
glow.style.transform = `scale(${1 + s * SCALE_AMP})`;
|
||||
},
|
||||
},
|
||||
BLOOM_START + BLOOM_DUR, // breathe begins right as the bloom settles
|
||||
);
|
||||
|
||||
// ── Form B: TRAVELING SWEEP ─────────────────────────────────────────
|
||||
// A single finite pass. opacity fades in, x travels from off one edge to
|
||||
// off the other, opacity fades out as it exits. One pass — no repeat.
|
||||
const sweep = document.getElementById("sweep");
|
||||
tl.fromTo(
|
||||
sweep,
|
||||
{ x: SWEEP_START_X, opacity: 0 },
|
||||
{
|
||||
x: SWEEP_END_X,
|
||||
opacity: SWEEP_PEAK_OPACITY,
|
||||
duration: SWEEP_DUR,
|
||||
ease: "none", // constant glide reads as a raking light, not an ease-in object
|
||||
},
|
||||
SWEEP_START,
|
||||
);
|
||||
// Fade the trailing edge out so it doesn't pop off at the surface edge.
|
||||
tl.to(sweep, { opacity: 0, duration: SWEEP_FADE_DUR, ease: "power1.in" }, SWEEP_FADE_START);
|
||||
|
||||
window.__timelines["bloom-scene"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
### Bloom-and-hold (no breathe)
|
||||
|
||||
For very short scenes (< 3s) or when the hero already has its own idle, skip Phase 2 entirely — bloom to peak and hold flat. The single `fromTo` is the whole recipe; the glow is just lit presence.
|
||||
|
||||
### Pulse-on-arrival (one swell, then settle to a lower hold)
|
||||
|
||||
Bloom slightly **past** peak, then ease back down to a steady hold level — a single breath that punctuates the hero's landing without an ongoing loop. Two adjacent tweens (state continuity, same as `press-release-spring`):
|
||||
|
||||
```js
|
||||
tl.fromTo(
|
||||
glow,
|
||||
{ opacity: 0, scale: GLOW_START_SCALE },
|
||||
{ opacity: GLOW_OVERSHOOT_OPACITY, scale: 1.06, duration: BLOOM_DUR, ease: "power2.out" },
|
||||
BLOOM_START,
|
||||
);
|
||||
tl.to(
|
||||
glow,
|
||||
{ opacity: GLOW_HOLD_OPACITY, scale: 1, duration: SETTLE_DUR, ease: "power2.inOut" },
|
||||
BLOOM_START + BLOOM_DUR,
|
||||
);
|
||||
```
|
||||
|
||||
### Multi-hero relay (staggered blooms behind a row of cards)
|
||||
|
||||
Bloom each card's glow on a stagger so presence sweeps across the row. Per-glow `BLOOM_START` offset by `STAGGER` (~0.15-0.3s); shrink `OPACITY_AMP` / `SCALE_AMP` per the concurrent-elements rule below so N breathing halos don't compound into a shimmer.
|
||||
|
||||
### Diagonal raked sweep (wordmark sheen)
|
||||
|
||||
Angle `{sweepGradient}` (e.g. a 105° linear gradient) and let the band travel left→right across a wordmark or logo lockup. Reads as light raking across a surface — the classic one-pass logo sheen. Same single-pass timeline; just a narrower `SWEEP_WIDTH` and a higher `SWEEP_PEAK_OPACITY` since it's a tight highlight on a small target.
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
### Glow geometry
|
||||
|
||||
- **GLOW_INSET** — negative inset so the radial halo extends past the hero edges.
|
||||
- Range: `-200` to `-450` px on a 1920×1080 canvas; larger halo for a bigger hero
|
||||
- Effects: too small and the glow is a tight rim, not ambient presence
|
||||
- **GLOW_START_SCALE** — scale at the start of bloom-in (the halo "inflates" to 1).
|
||||
- Range: 0.80 (clear inflation) → 0.92 (subtle) → 1.0 (no swell, opacity-only bloom)
|
||||
- Constraints: keep ≤ 1.0 — the swell should grow into place, not shrink
|
||||
|
||||
### Bloom-in dynamics
|
||||
|
||||
- **BLOOM_DUR** — bloom-in duration.
|
||||
- Range: 0.6-1.4s; longer for a hero that's still settling so they land together
|
||||
- Effects: shorter → the glow "snaps on"; longer → it suffuses in (the ambient feel)
|
||||
- **BLOOM_START** — when the bloom begins.
|
||||
- Constraints: align so `BLOOM_START + BLOOM_DUR` ≈ the hero's settle frame, so glow and hero resolve as one beat — not glow-then-card or card-then-glow
|
||||
- **GLOW_PEAK_OPACITY** — peak halo opacity.
|
||||
- Range: 0.15 (subtle) → 0.30 (default) → 0.45 (dramatic)
|
||||
- **Constraints: ≤ 0.45** — higher washes the whole frame and the hero loses contrast against its own glow
|
||||
|
||||
### Idle breathe (hero-bloom form)
|
||||
|
||||
- **BREATHE_DUR** — breathe tween length.
|
||||
- Constraints: equals `TOTAL_DURATION − (BLOOM_START + BLOOM_DUR)` to fill the hold with motion
|
||||
- **BREATHE_CYCLES** — number of full breaths across `BREATHE_DUR`.
|
||||
- Range: `BREATHE_DUR / 4s ≤ CYCLES ≤ BREATHE_DUR / 2.5s` (a 2.5-4s breath period reads as a slow ambient pulse — glow breathing wants to be slower than element breathing)
|
||||
- **OPACITY_AMP** — sine amplitude on opacity around the peak.
|
||||
- **Default: 0.02-0.05** (barely-perceptible pulse — the right answer for most scenes)
|
||||
- Constraints: `GLOW_PEAK_OPACITY + OPACITY_AMP` must stay ≤ 0.45
|
||||
- **SCALE_AMP** — sine amplitude on the halo scale.
|
||||
- **Default: 0.01-0.03** (the halo "breathes" without visibly resizing)
|
||||
- Push higher only when the glow is the sole motion in a short isolated scene
|
||||
|
||||
### Traveling sweep
|
||||
|
||||
- **SWEEP_WIDTH** — width of the soft highlight band.
|
||||
- Range: 15-35% of the surface width (a wide soft band) for a grid sheen; 8-15% for a tight wordmark sheen
|
||||
- **SWEEP_START_X / SWEEP_END_X** — travel endpoints, both fully off-surface.
|
||||
- Constraints: start ≈ `-(SWEEP_WIDTH + edge)`, end ≈ `surfaceWidth + edge` — the band must enter from fully off one edge and exit fully off the other, so there's no visible spawn/despawn mid-surface
|
||||
- **SWEEP_DUR** — single-pass travel duration.
|
||||
- Range: 0.8-1.6s; one deliberate pass, slow enough to read as light, fast enough not to dominate
|
||||
- **SWEEP_PEAK_OPACITY** — highlight opacity.
|
||||
- Range: 0.10 (whisper sheen) → 0.25 (default) → 0.40 (bright rake)
|
||||
- Constraints: ≤ ~0.45 (same wash limit); tighter sweeps tolerate the high end
|
||||
- **SWEEP_START / SWEEP_FADE_START / SWEEP_FADE_DUR** — when the pass runs and tails out.
|
||||
- Constraints: `SWEEP_FADE_START + SWEEP_FADE_DUR ≈ SWEEP_START + SWEEP_DUR` so opacity reaches 0 exactly as the band clears the far edge
|
||||
|
||||
### Tokens
|
||||
|
||||
- **{glowGradient}** — radial-gradient, saturated near center fading to transparent. Color should be **darker + more saturated** than `{heroBg}` — a same-color glow looks washed out (same rule as `press-release-spring`'s burst).
|
||||
- **{sweepGradient}** — a soft band: `transparent → highlight → transparent`. For a sheen, a near-white or brand-tint highlight at low alpha; angle it (e.g. `linear-gradient(105deg, …)`) for a raked look.
|
||||
- **{heroBg} / {heroTextColor}** — the hero surface the glow backs; high contrast so the lit hero still reads against its halo.
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Un-triggered by design** — this glow does NOT wait on a click (`press-release-spring`) or a word timestamp (`asr-keyword-glow`). It blooms on the hero's settle as ambient presence. If you need a triggered burst, reach for one of those rules instead.
|
||||
- **Glow behind, hero in front** — glow `z-index: 1`, hero `z-index: 2`. A glow in front occludes the hero at peak opacity.
|
||||
- **Glow color darker + more saturated than the element** — bright hero → dark, saturated halo. A same-hue, same-lightness glow disappears into the surface.
|
||||
- **Land glow and hero as ONE beat** — time `BLOOM_START + BLOOM_DUR` to the hero's settle. A glow that arrives before or after the card reads as two separate events; arriving together reads as the card "powering on."
|
||||
- **Restrained peak — default to the LOW end.** `GLOW_PEAK_OPACITY` 0.15-0.30 for most scenes; 0.45 is a hard ceiling. A glow you consciously notice is too strong — it should register as the hero having weight, not as a visible light source.
|
||||
- **Breathe is BOUNDED, never a loop** — the idle pulse is a finite `onUpdate` tween reading `tl.time()` (via the `phase` proxy), not `repeat: -1` / `yoyo`. `sin(0) = 0` means it starts at the bloom's resting state with no jump. (Same reason as `sine-wave-loop`: an infinite/CSS loop desyncs from the HF seek clock.)
|
||||
- **Sweep is ONE pass** — the traveling highlight enters off one edge and exits off the other a single time. No return trip, no loop. A repeating sweep reads as a loading shimmer, not a one-time reveal accent.
|
||||
- **Concurrent halos compound** — N breathing glows in a row add up. Per-glow `OPACITY_AMP` and `SCALE_AMP` ≤ default `/ √N`, and stagger the breathe period (2.6s / 2.9s / 3.3s) so they don't pulse in lockstep. (Same `/√N` discipline as `sine-wave-loop`'s concurrent-elements rule.)
|
||||
- **Don't combine `boxShadow` glow on the hero with this halo layer** — they compete in the layout pipeline and the result reads muddy. Put the glow on the dedicated `.bloom-glow` layer, not as a shadow on the hero.
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Timeline must be paused**: `gsap.timeline({ paused: true })`
|
||||
- **Registry key = `data-composition-id`**
|
||||
- **No CSS `transition`** on the glow / sweep — interpolates independently of HF seek and flickers
|
||||
- **No `repeat` / `yoyo` / `repeat: -1`** — the breathe is a bounded finite tween; the sweep is one pass
|
||||
- **No `Math.random` / `Date.now`** — the breathe phase is deterministic (`phase.p` over a fixed duration)
|
||||
- **GSAP transform aliases only**: `x`, `y`, `scale`, `rotation` — plus `opacity` / `filter`. Never tween `width` / `height` / `left` / `top` (the halo swell is `scale`, the sweep travel is `x`).
|
||||
- **`will-change: transform, opacity`** on the glow — it animates both during bloom-in and the breathe
|
||||
- **Glow peak `opacity ≤ 0.45`** — higher washes the composition
|
||||
- **Sweep endpoints fully off-surface** — band must enter and exit beyond the clipped edges so it never spawns/despawns mid-frame
|
||||
|
||||
## Combinations
|
||||
|
||||
- [sine-wave-loop.md](sine-wave-loop.md) — pair the hero-bloom form with a sine breathe on the hero element itself; the glow breathes on opacity, the hero breathes on scale/y, slightly out of phase for a layered "alive" hold
|
||||
- [press-release-spring.md](press-release-spring.md) — distinct sibling: that rule's `bg-glow` is **click-triggered**, this one is un-triggered. Don't run both behind the same element
|
||||
- [counting-dynamic-scale.md](counting-dynamic-scale.md) — bloom the accent halo behind the hero stat card on the count-up's settle (the `dataviz-countup` blueprint's "soft accent glow blooms behind the hero metric" beat)
|
||||
- [stat-bars-and-fills.md](stat-bars-and-fills.md) — glow blooms behind the hero metric + its paired graphic as they land together
|
||||
- [center-outward-expansion.md](center-outward-expansion.md) — run the traveling-sweep across the assembled layout once it resolves (the `grid-card-assemble` blueprint's "traveling-glow sweep across the assembled grid")
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — `onUpdate` writing opacity/transform + bounded sine breathe
|
||||
- `/hyperframes-core` — composition wiring
|
||||
- `/hyperframes-cli` — `hyperframes lint`
|
||||
@@ -0,0 +1,286 @@
|
||||
---
|
||||
name: asr-keyword-glow
|
||||
description: Keywords glow + scale up when "spoken" — attack/sustain/release envelope synced to per-word timestamps. Even without real audio, hardcoded timings create a "narrator emphasis" effect.
|
||||
metadata:
|
||||
tags: asr, audio-sync, highlight, glow, keyword, text, speech, emphasis
|
||||
---
|
||||
|
||||
# ASR Keyword Glow
|
||||
|
||||
Words in a phrase visually activate (glow blur + scale) when "spoken," following an attack-sustain-release (ASR-like) envelope. In a real ASR pipeline these timings come from word-level transcript data; for promotional video, hardcode the timings to control emphasis pacing. The envelope leaves a subtle "rest glow" after the word, creating a breadcrumb of recent emphasis.
|
||||
|
||||
## How It Works
|
||||
|
||||
Each word has `{ start, end }` timestamps. At each frame, compute the word's envelope value:
|
||||
|
||||
- **Pre-start** → 0 (not yet)
|
||||
- **Start → peak** → attack (linear ramp 0 → 1)
|
||||
- **Peak → end** → sustain (stays at 1)
|
||||
- **End → end+release** → decay (1 → restLevel, typically 0.25)
|
||||
- **After release** → restLevel (stays subtly highlighted)
|
||||
|
||||
The envelope drives `textShadow` blur radius AND `scale`. Higher blur + bigger scale = "speaking" emphasis.
|
||||
|
||||
## HTML
|
||||
|
||||
```html
|
||||
<div
|
||||
class="scene"
|
||||
id="asr-scene"
|
||||
data-composition-id="asr-scene"
|
||||
data-start="0"
|
||||
data-duration="6"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div class="phrase">
|
||||
<!-- One <span class="word"> per word in {phrase}. data-word is a
|
||||
stable key used to look up the word's ASR timing in JS. The
|
||||
final word may be a {brandWord}, which gets longer emphasis
|
||||
and the .brand modifier. -->
|
||||
<span class="word" data-word="{w1Key}">{w1}</span>
|
||||
<span class="word" data-word="{w2Key}">{w2}</span>
|
||||
<!-- … -->
|
||||
<span class="word brand" data-word="{brandKey}">{brandWord}</span>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS
|
||||
|
||||
```css
|
||||
.scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: {sceneBackgroundColor};
|
||||
font-family: {font};
|
||||
}
|
||||
.phrase {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
justify-content: center;
|
||||
max-width: 1700px;
|
||||
font-size: 120px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 2px;
|
||||
color: {restColor};
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.word {
|
||||
display: inline-block;
|
||||
transform-origin: 50% 50%;
|
||||
/* Initial subtle rest glow */
|
||||
text-shadow: 0 0 0 {glowColorTransparent};
|
||||
will-change: transform, text-shadow;
|
||||
}
|
||||
.word.brand {
|
||||
color: {brandAccentColor};
|
||||
letter-spacing: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// Per-word "spoken" times — author these to control narrator pacing.
|
||||
// Shape: { [wordKey]: { start, end } } in local seconds. One entry
|
||||
// per <span class="word" data-word="…">. The brand word's window is
|
||||
// typically 1.5-2× as long as a normal word.
|
||||
const TIMINGS = {
|
||||
// {w1Key}: { start: …, end: … },
|
||||
// {w2Key}: { start: …, end: … },
|
||||
// …
|
||||
// {brandKey}: { start: …, end: … },
|
||||
};
|
||||
|
||||
function envelope(time, start, end) {
|
||||
const releaseEnd = end + RELEASE;
|
||||
if (time < start) return 0;
|
||||
if (time < end) {
|
||||
// attack — linear ramp over ATTACK_DUR then sustain
|
||||
const attack = Math.min((time - start) / ATTACK_DUR, 1);
|
||||
return attack;
|
||||
}
|
||||
if (time < releaseEnd) {
|
||||
// decay to rest level
|
||||
const decay = (time - end) / RELEASE;
|
||||
return 1 - decay * (1 - REST_LEVEL);
|
||||
}
|
||||
return REST_LEVEL;
|
||||
}
|
||||
|
||||
const words = document.querySelectorAll(".word");
|
||||
|
||||
// Single driver — 0 → composition duration
|
||||
const driver = { t: 0 };
|
||||
tl.to(
|
||||
driver,
|
||||
{
|
||||
t: SCENE_DURATION,
|
||||
duration: SCENE_DURATION,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
words.forEach((el) => {
|
||||
const word = el.dataset.word;
|
||||
const timing = TIMINGS[word];
|
||||
if (!timing) return;
|
||||
const env = envelope(driver.t, timing.start, timing.end);
|
||||
const blur = MAX_BLUR * env;
|
||||
const scale = 1 + MAX_SCALE_BOOST * env;
|
||||
el.style.textShadow = `0 0 ${blur}px ${glowColorRgba(env)}`;
|
||||
el.style.transform = `scale(${scale})`;
|
||||
});
|
||||
},
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
window.__timelines["asr-scene"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
`glowColorRgba(env)` returns the brand glow color with `env`-modulated alpha (e.g. `rgba({glowR}, {glowG}, {glowB}, ${GLOW_ALPHA_BASE + env * GLOW_ALPHA_RANGE})`).
|
||||
|
||||
## Variations
|
||||
|
||||
### Multi-octave glow (more dramatic peaks)
|
||||
|
||||
Combine the envelope-driven blur with a sin pulse during the sustain phase — high-emphasis words breathe at peak. The sine frequency `PULSE_HZ` controls how many breaths fit in the sustain window; amplitude `PULSE_AMPLITUDE` controls how visible the breath is.
|
||||
|
||||
```js
|
||||
const sustain = env * (1 + Math.sin(driver.t * PULSE_HZ) * PULSE_AMPLITUDE);
|
||||
const blur = MAX_BLUR * sustain;
|
||||
```
|
||||
|
||||
### Color shift on the peak
|
||||
|
||||
The active word lerps from `restColor` → `peakColor` as `env` rises, settling back to `restColor` at rest:
|
||||
|
||||
```js
|
||||
function lerpChannel(a, b, t) {
|
||||
return Math.round(a + (b - a) * t);
|
||||
}
|
||||
el.style.color = `rgb(${lerpChannel(REST_RGB.r, PEAK_RGB.r, env)}, ${lerpChannel(REST_RGB.g, PEAK_RGB.g, env)}, ${lerpChannel(REST_RGB.b, PEAK_RGB.b, env)})`;
|
||||
```
|
||||
|
||||
### Karaoke style (dim-rest + bright-active, RECOMMENDED for video narration)
|
||||
|
||||
Default amplitudes (small MAX_BLUR, small MAX_SCALE_BOOST, rest text full white) read as too subtle in video — the inactive words still dominate. Karaoke style fixes this: **inactive words rendered DIM**, active words **lerp toward bright white + larger scale**:
|
||||
|
||||
```js
|
||||
// Tunable constants — see How to Choose Values
|
||||
// REST_RGB — dim color for inactive words
|
||||
// ACTIVE_RGB — bright color at peak (non-brand)
|
||||
// BRAND_RGB — bright color at peak (brand word)
|
||||
// MAX_BLUR, MAX_SCALE_BOOST, REST_LEVEL all pushed higher than default
|
||||
|
||||
function lerpChannel(a, b, t) {
|
||||
return Math.round(a + (b - a) * t);
|
||||
}
|
||||
function colorAt(env, isBrand) {
|
||||
const target = isBrand ? BRAND_RGB : ACTIVE_RGB;
|
||||
return `rgb(${lerpChannel(REST_RGB.r, target.r, env)}, ${lerpChannel(REST_RGB.g, target.g, env)}, ${lerpChannel(REST_RGB.b, target.b, env)})`;
|
||||
}
|
||||
|
||||
// In onUpdate:
|
||||
el.style.color = colorAt(env, el.classList.contains("brand"));
|
||||
```
|
||||
|
||||
Visual result: at any moment 1-2 words are bright + glowing (the spoken word + the recently-spoken one's lingering rest), and the rest of the phrase is dim. This is closer to actual karaoke / lyric video aesthetic than the subtle "everyone half-glowing" baseline.
|
||||
|
||||
When to use karaoke vs default: short narration phrases (5-10 words) where one word at a time should clearly POP → karaoke. Long dense text where many words emphasize subtly → default subtle. Karaoke pushes MAX_BLUR, MAX_SCALE_BOOST, and contrast between REST_RGB and ACTIVE_RGB; everything else is identical.
|
||||
|
||||
### 3D pop-out
|
||||
|
||||
Combine envelope with `translateZ` for words to "lean toward camera" as they speak:
|
||||
|
||||
```js
|
||||
const popZ = env * MAX_POP_Z;
|
||||
el.style.transform = `translateZ(${popZ}px) scale(${scale})`;
|
||||
```
|
||||
|
||||
Requires `perspective` on the parent.
|
||||
|
||||
### From real ASR transcripts
|
||||
|
||||
For real ASR-driven scenes, replace hardcoded TIMINGS with transcript JSON (each entry has `word`, `start_ms`, `end_ms`). Convert to seconds and feed in identically. The shape `{ [wordKey]: { start, end } }` is the same whether hand-authored or derived from `hyperframes transcribe`.
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
- **TIMINGS** — per-word `{ start, end }` map. Author one entry per `.word` span.
|
||||
- Shape: `{ wordKey: { start: number, end: number } }`, all seconds local to the scene.
|
||||
- Constraints: monotonic non-overlap — every entry's `end < next entry's start` (overlapping windows make the envelope ambiguous).
|
||||
- Brand word window: typically 1.5-2× the average non-brand word window so the brand sustains.
|
||||
- **ATTACK_DUR** — seconds for the envelope to ramp 0 → 1 once a word starts.
|
||||
- Range: 0.1-0.25 s
|
||||
- Effects: shorter feels punchy and ASR-like; longer feels smoothed-out.
|
||||
- Constraints: must be < (smallest word's end - start), otherwise the word never reaches 1.
|
||||
- **RELEASE** — seconds for the envelope to decay 1 → REST_LEVEL after a word ends.
|
||||
- Range: 0.2-0.5 s
|
||||
- **REST_LEVEL** — held envelope value after RELEASE.
|
||||
- Range: 0.15-0.4 (default style); 0.05-0.2 (karaoke style — dimmer rest).
|
||||
- Effects: lower = quieter breadcrumb; higher = more recently-spoken words stay bright.
|
||||
- Constraints: must be < 1; should be > 0 to preserve the breadcrumb.
|
||||
- **MAX_BLUR** — peak `text-shadow` blur radius in px.
|
||||
- Range: 15-25 px (default style); 30-45 px (karaoke style).
|
||||
- Effects: bigger reads as "shouting"; smaller reads as "neutral narration".
|
||||
- **MAX_SCALE_BOOST** — additive scale at peak (e.g. 0.08 ⇒ 1.0 → 1.08).
|
||||
- Range: 0.03-0.10 (default style); 0.15-0.25 (karaoke style).
|
||||
- Effects: bigger reads as "bouncy"; smaller reads as "just glowing".
|
||||
- **SCENE_DURATION** — total seconds for the single driver tween.
|
||||
- Constraints: must equal the scene's `data-duration` so the driver `t` reaches the end of TIMINGS in sync with HF's seek.
|
||||
- **REST_RGB / ACTIVE_RGB / BRAND_RGB** (karaoke style) — discrete color choices, not numeric.
|
||||
- REST_RGB: dim tone of the brand palette's neutral; should read as off-white-ish dim, not black.
|
||||
- ACTIVE_RGB: brand text color at full readability.
|
||||
- BRAND_RGB: brand accent color (often the same hue as the glow).
|
||||
- **PULSE_HZ / PULSE_AMPLITUDE** (multi-octave variation) — sine breath frequency / depth.
|
||||
- PULSE_HZ range: 4-10 rad/s; PULSE_AMPLITUDE range: 0.1-0.3.
|
||||
- **MAX_POP_Z** (3D pop-out variation) — max Z translation at peak (px).
|
||||
- Range: 20-60 px; requires parent `perspective`.
|
||||
|
||||
Ease family — discrete choice:
|
||||
|
||||
- Single linear driver (`ease: "none"`) so `t` maps 1:1 to scene time. Any other ease distorts the per-word envelope shape — do not change.
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Envelope shape: attack-sustain-decay-rest** — never zero out after a word. The rest level (REST_LEVEL > 0) keeps the recently-spoken words subtly highlighted, creating a "breadcrumb" of attention.
|
||||
- **Brand word gets longer emphasis (1.5-2× normal)** — the brand is the headline; let it sustain.
|
||||
- **`display: inline-block`** on each word — required for `transform` to apply to `<span>`.
|
||||
- **MAX_BLUR and MAX_SCALE_BOOST stay in their default-style ranges unless you commit to karaoke** — picking values between default and karaoke yields awkward "half-loud" emphasis.
|
||||
- **Per-word `text-shadow`** (not `box-shadow`) — text-shadow is the glow around the GLYPH, which is what reads as "speaking emphasis." Box-shadow would glow around the inline-block bounding box (rectangle).
|
||||
- **Single driver, multi-word onUpdate** — one tween that loops over all words. Don't create one tween per word — at 60+ words the timeline becomes unwieldy.
|
||||
- **❗ Climax dwell ≥1s** — after the final word's emphasis, comp continues ≥1s. The last word IS the headline beat.
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Timeline must be paused**: `gsap.timeline({ paused: true })`
|
||||
- **Registry key = `data-composition-id`**
|
||||
- **No CSS animation** on word elements
|
||||
- **`display: inline-block`** on each `.word`
|
||||
- **`will-change: transform, text-shadow`** on `.word`
|
||||
- **Timings monotonic** (later start > earlier end) — overlapping words mess up the envelope
|
||||
|
||||
## Combinations
|
||||
|
||||
- [3d-text-depth-layers.md](3d-text-depth-layers.md) — the active word gets depth-layered emphasis at peak
|
||||
- [sine-wave-loop.md](sine-wave-loop.md) — non-active words breathe subtly between emphasis moments
|
||||
- [context-sensitive-cursor.md](context-sensitive-cursor.md) — typewriter that types each word matching the ASR cadence
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — single driver, multi-element envelope
|
||||
- `/media-use` — `hyperframes transcribe` outputs real ASR data
|
||||
- `/media-use` — pair with caption rendering
|
||||
- `/hyperframes-core` — composition wiring
|
||||
- `/hyperframes-cli` — `hyperframes lint`
|
||||
@@ -0,0 +1,371 @@
|
||||
---
|
||||
name: avatar-cloud-network
|
||||
description: Avatars distributed on an elliptical ring connected by SVG dashed lines to a center hub — social proof "community" reveal with staggered entry.
|
||||
metadata:
|
||||
tags: avatar, cloud, network, social-proof, ellipse, connection, stagger
|
||||
---
|
||||
|
||||
# Avatar Cloud Network
|
||||
|
||||
Avatars arranged on an elliptical ring around a central element (logo / counter / brand). SVG dashed connection lines from center to each avatar. Staggered spring entry on avatars, then connection lines draw outward — communicates "community" or "social proof." Distinct from [orbit-3d-entry](orbit-3d-entry.md) (which continuously orbits) — avatar-cloud is a static composed reveal.
|
||||
|
||||
## How It Works
|
||||
|
||||
Three rendering layers:
|
||||
|
||||
1. **SVG connection lines** (z-index 1, behind everything) — line from center hub to each avatar's position
|
||||
2. **Avatars** (z-index 2) — `<div>` circles on elliptical positions
|
||||
3. **Center hub** (z-index 5) — brand counter or logo (sits ABOVE the lines that converge on it)
|
||||
|
||||
Animation phases:
|
||||
|
||||
- `HUB_FADE_START → HUB_FADE_START + HUB_FADE_DUR`: hub fades in
|
||||
- `AVATAR_ENTRY_START → AVATAR_ENTRY_START + (AVATAR_COUNT − 1) × AVATAR_STAGGER + AVATAR_ENTRY_DUR`: avatars cascade in
|
||||
- `LINES_START → LINES_START + (AVATAR_COUNT − 1) × LINE_STAGGER + LINES_DUR`: connection lines draw outward
|
||||
- climax dwell: optional idle breathing on avatars (see Variations / sine-wave-loop)
|
||||
|
||||
## HTML
|
||||
|
||||
```html
|
||||
<div
|
||||
class="scene"
|
||||
id="cloud-scene"
|
||||
data-composition-id="cloud-scene"
|
||||
data-start="0"
|
||||
data-duration="4"
|
||||
data-track-index="0"
|
||||
>
|
||||
<!-- Connection lines layer -->
|
||||
<svg class="lines" viewBox="0 0 1920 1080" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Lines injected by script — center to each avatar -->
|
||||
</svg>
|
||||
|
||||
<!-- Avatars + center hub -->
|
||||
<div class="hub-wrap">
|
||||
<div class="hub" id="hub">
|
||||
<div class="hub-num" id="hub-num">{counterValue}</div>
|
||||
<div class="hub-label">{counterLabel}</div>
|
||||
</div>
|
||||
<!-- Avatars injected by script — AVATAR_COUNT around the ellipse -->
|
||||
</div>
|
||||
|
||||
<div class="brand">{footerLine}</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Placeholder tokens:
|
||||
|
||||
- `{counterValue}` / `{counterLabel}` — the hub copy (numeric proof + category)
|
||||
- `{footerLine}` — optional attribution line under the cloud
|
||||
- `{avatar[i]}` — per-avatar image source (or emoji glyph if using the emoji variation below)
|
||||
|
||||
## CSS
|
||||
|
||||
```css
|
||||
.scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: {bgColor};
|
||||
font-family: {font};
|
||||
overflow: hidden;
|
||||
}
|
||||
.lines {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
.hub-wrap {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.hub {
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 48px 64px;
|
||||
border-radius: 28px;
|
||||
background: {hubBg};
|
||||
border: 1px solid {hubBorder};
|
||||
}
|
||||
.hub-num {
|
||||
font-size: HUB_NUM_FONT_SIZE;
|
||||
font-weight: 900;
|
||||
color: {textColor};
|
||||
letter-spacing: -4px;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.hub-label {
|
||||
font-size: HUB_LABEL_FONT_SIZE;
|
||||
font-weight: 800;
|
||||
letter-spacing: 12px;
|
||||
color: {accentColor};
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.avatar {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
width: AVATAR_SIZE;
|
||||
height: AVATAR_SIZE;
|
||||
border-radius: 50%;
|
||||
border: 3px solid {avatarBorder};
|
||||
box-shadow:
|
||||
0 12px 32px rgba(0, 0, 0, 0.5),
|
||||
0 0 24px {avatarGlow};
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: AVATAR_GLYPH_SIZE;
|
||||
background: {avatarBg};
|
||||
will-change: transform, opacity;
|
||||
/* Top-left positioned by script; transform centers via -50% trick */
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.brand {
|
||||
position: absolute;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: BRAND_FONT_SIZE;
|
||||
font-weight: 900;
|
||||
letter-spacing: 14px;
|
||||
color: {accentColor};
|
||||
text-transform: uppercase;
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// CENTER_X / CENTER_Y must match the hub's rendered center exactly.
|
||||
// For a hub-wrap with place-items:center on a 1920×1080 canvas these are
|
||||
// (compositionWidth/2, compositionHeight × CENTER_Y_FACTOR).
|
||||
const SCREEN_CENTER = { x: CENTER_X, y: CENTER_Y };
|
||||
|
||||
const hubWrap = document.querySelector(".hub-wrap");
|
||||
const linesSvg = document.querySelector(".lines");
|
||||
|
||||
// Build avatars + lines
|
||||
const avatarPositions = [];
|
||||
for (let i = 0; i < AVATAR_COUNT; i++) {
|
||||
const angle = (i / AVATAR_COUNT) * Math.PI * 2 - Math.PI / 2; // start at top
|
||||
const x = SCREEN_CENTER.x + Math.cos(angle) * RADIUS_X;
|
||||
const y = SCREEN_CENTER.y + Math.sin(angle) * RADIUS_Y;
|
||||
avatarPositions.push({ x, y, angle });
|
||||
|
||||
// Avatar
|
||||
const av = document.createElement("div");
|
||||
av.className = "avatar";
|
||||
av.style.left = `${x}px`;
|
||||
av.style.top = `${y}px`;
|
||||
// assign avatar image / glyph from your authoring data (see {avatar[i]})
|
||||
hubWrap.appendChild(av);
|
||||
|
||||
// Line from hub to avatar
|
||||
const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
|
||||
line.setAttribute("x1", SCREEN_CENTER.x);
|
||||
line.setAttribute("y1", SCREEN_CENTER.y);
|
||||
line.setAttribute("x2", x);
|
||||
line.setAttribute("y2", y);
|
||||
line.setAttribute("stroke", "{lineColor}");
|
||||
line.setAttribute("stroke-width", "2");
|
||||
line.setAttribute("stroke-dasharray", "6 8");
|
||||
const len = Math.hypot(x - SCREEN_CENTER.x, y - SCREEN_CENTER.y);
|
||||
line.style.strokeDashoffset = String(len);
|
||||
line.dataset.length = String(len);
|
||||
line.dataset.index = String(i);
|
||||
linesSvg.appendChild(line);
|
||||
}
|
||||
|
||||
// Phase 1 — hub fade in
|
||||
tl.from(
|
||||
".hub",
|
||||
{ opacity: 0, scale: 0.8, duration: HUB_FADE_DUR, ease: `back.out(${HUB_BOUNCE})` },
|
||||
HUB_FADE_START,
|
||||
);
|
||||
|
||||
// Phase 2 — avatars cascade-in (staggered spring)
|
||||
const avatars = document.querySelectorAll(".avatar");
|
||||
avatars.forEach((av, i) => {
|
||||
tl.from(
|
||||
av,
|
||||
{
|
||||
opacity: 0,
|
||||
scale: 0,
|
||||
duration: AVATAR_ENTRY_DUR,
|
||||
ease: `back.out(${AVATAR_BOUNCE})`,
|
||||
},
|
||||
AVATAR_ENTRY_START + i * AVATAR_STAGGER,
|
||||
);
|
||||
});
|
||||
|
||||
// Phase 3 — connection lines draw outward (staggered) — starts after most avatars land
|
||||
const lines = linesSvg.querySelectorAll("line");
|
||||
lines.forEach((line, i) => {
|
||||
tl.to(
|
||||
line,
|
||||
{
|
||||
strokeDashoffset: 0,
|
||||
duration: LINES_DUR,
|
||||
ease: "power2.out",
|
||||
},
|
||||
LINES_START + i * LINE_STAGGER,
|
||||
);
|
||||
});
|
||||
|
||||
// Phase 4 — climax dwell (network fully formed), idle breathing on avatars
|
||||
// (see sine-wave-loop.md for the multiplicative onUpdate form)
|
||||
const breathDriver = { p: 0 };
|
||||
tl.to(
|
||||
breathDriver,
|
||||
{
|
||||
p: Math.PI * 2 * BREATH_CYCLES,
|
||||
duration: BREATH_DUR,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
avatars.forEach((av, i) => {
|
||||
const phase = breathDriver.p + (i / avatars.length) * Math.PI * 2;
|
||||
const s = 1 + Math.sin(phase) * BREATH_AMP;
|
||||
av.style.transform = `translate(-50%, -50%) scale(${s})`;
|
||||
});
|
||||
},
|
||||
},
|
||||
BREATH_START,
|
||||
);
|
||||
|
||||
window.__timelines["cloud-scene"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
### Geometry
|
||||
|
||||
- **CENTER_X / CENTER_Y** — px coordinates of the hub center; lines and avatar positions derive from these.
|
||||
- Constraints: **must equal the hub's actual rendered center** — when this rule is composed with another scene (e.g. a logo that has been recentered), `CENTER_X / CENTER_Y` must be baked from the same source as the hub's final position
|
||||
- Reference: ../../examples/proof-logo-chain.html uses `(W/2, H × 0.47)` so the cloud sits slightly above the canvas midline
|
||||
- **RADIUS_X / RADIUS_Y** — ellipse radii in px (RADIUS_X ≥ RADIUS_Y reads as perspective).
|
||||
- Range: `RADIUS_X` ~ 20-30% of viewport width; `RADIUS_Y` ~ 18-25% of viewport height
|
||||
- Constraints: `RADIUS_X / RADIUS_Y` ratio between 1.5 and 3.0 reads as natural depth; ratio = 1 (circle) reads as a flat 2D layout
|
||||
- Reference: ../../examples/proof-logo-chain.html uses `W * 0.25` (`480px`) and `H * 0.22` (`237.6px`)
|
||||
- **AVATAR_COUNT** — number of avatars distributed around the ring.
|
||||
- Range: 8-12; fewer feels sparse, more clutters the ellipse
|
||||
- Reference: ../../examples/proof-logo-chain.html uses `10`
|
||||
- **AVATAR_SIZE / AVATAR_GLYPH_SIZE** — px diameter of each avatar circle and (optional) inner glyph size.
|
||||
- Range: `AVATAR_SIZE` ~ 80-120 px at 1920 wide; small enough that 10+ avatars fit the ring without overlap
|
||||
- **HUB_NUM_FONT_SIZE / HUB_LABEL_FONT_SIZE / BRAND_FONT_SIZE** — hub typography.
|
||||
- Constraints: hub-num is the focal beat, sized 2-4× the label
|
||||
|
||||
### Hub fade
|
||||
|
||||
- **HUB_FADE_START** — when the hub fades in.
|
||||
- Range: usually `0` (the hub establishes the focal point); offset if the scene precedes with another beat
|
||||
- **HUB_FADE_DUR** — hub fade-in duration.
|
||||
- Range: 0.4-0.6s
|
||||
- **HUB_BOUNCE** — `back.out(HUB_BOUNCE)` coefficient on the hub's scale entry.
|
||||
- Range: 1.4 (subtle) → 1.8 (firm)
|
||||
|
||||
### Avatar cascade
|
||||
|
||||
- **AVATAR_ENTRY_START** — when the first avatar pops in.
|
||||
- Constraints: `≥ HUB_FADE_START + HUB_FADE_DUR × 0.6` so the hub is established before satellites arrive
|
||||
- **AVATAR_ENTRY_DUR** — per-avatar scale-up duration.
|
||||
- Range: 0.4-0.7s
|
||||
- **AVATAR_STAGGER** — delay between consecutive avatar entries.
|
||||
- Range: 0.06-0.10s; cascade reads as "joining"; simultaneous reads as "all already there"
|
||||
- **AVATAR_BOUNCE** — `back.out(AVATAR_BOUNCE)` coefficient on each avatar's pop.
|
||||
- Range: 1.4 (gentle) → 1.8 (firm); slightly firmer than hub for differentiation
|
||||
|
||||
### Connection lines
|
||||
|
||||
- **LINES_START** — when the lines begin drawing outward.
|
||||
- Constraints: `LINES_START + (AVATAR_COUNT − 1) × LINE_STAGGER` should overlap the last avatar's settle by ~0.1-0.2s so the drawing reads as a consequence of the avatars landing
|
||||
- **LINES_DUR** — per-line draw duration (strokeDashoffset → 0).
|
||||
- Range: 0.4-0.7s
|
||||
- **LINE_STAGGER** — delay between consecutive lines starting.
|
||||
- Range: 0.02-0.05s; tight stagger reads as a wave outward
|
||||
|
||||
### Idle breathing
|
||||
|
||||
- **BREATH_START** — when idle breathing activates.
|
||||
- Constraints: `≥ LINES_START + (AVATAR_COUNT − 1) × LINE_STAGGER + LINES_DUR + ~0.2s` (let the lines settle)
|
||||
- **BREATH_DUR** — total duration of the breathing tween.
|
||||
- Range: fills the remaining composition window
|
||||
- **BREATH_CYCLES** — number of full sine cycles across `BREATH_DUR`.
|
||||
- Range: 1.0-2.0; under 1 reads as a single sigh, over 2 starts to look anxious
|
||||
- **BREATH_AMP** — sine amplitude on scale (multiplicative).
|
||||
- Range: 0.02-0.06; smaller for headshots, larger for stylized glyphs
|
||||
|
||||
### Color tokens
|
||||
|
||||
- **{bgColor}** — stage background (typically a dark gradient so the cloud reads as a constellation)
|
||||
- **{textColor}** — hub-num color (primary copy)
|
||||
- **{accentColor}** — hub-label + footer (the brand voice)
|
||||
- **{hubBg} / {hubBorder}** — hub card surfaces; gradient + 1px border reads as elevated
|
||||
- **{avatarBg} / {avatarBorder} / {avatarGlow}** — avatar circle styling; soft border + glow keeps them legible on dark backgrounds
|
||||
- **{lineColor}** — SVG stroke color (translucent accent reads as networky)
|
||||
- **{font}** — base typography stack
|
||||
|
||||
## Variations
|
||||
|
||||
### Avatar size variation (organic feel)
|
||||
|
||||
Vary avatar sizes by index — e.g. a small index-keyed array of sizes — so the ring doesn't read as rigidly repetitive.
|
||||
|
||||
### Solid lines instead of dashed
|
||||
|
||||
Drop `stroke-dasharray` and use a solid stroke. Drop the dash-draw animation; lines fade in via opacity instead. More corporate, less networky.
|
||||
|
||||
### Multi-orbit (concentric rings)
|
||||
|
||||
Two layers of avatars: smaller inner ring (fewer avatars, slightly larger size), larger outer ring (more, smaller). Lines connect ONLY inner ring to hub; outer ring is a "halo."
|
||||
|
||||
### Country / role glyphs (geographic or persona spread)
|
||||
|
||||
Replace face images with flags / emoji / iconography. Reads as "global community" or "diverse roles."
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Hub above lines (`z-index: 5` vs lines `z-index: 1`)** — lines should appear to terminate AT the hub edge, not pass through. Hub must be in front.
|
||||
- **Lines drawn outward (dash offset 0)** — drawing FROM center is the visual narrative: "the hub connects to its community."
|
||||
- **8-12 avatars** — fewer feels sparse, more clutters the ellipse.
|
||||
- **`RADIUS_X > RADIUS_Y`** — horizontal ellipse reads as perspective; equal radii (circle) reads as 2D flat layout.
|
||||
- **Avatar entry stagger 0.06-0.10s** — cascade reads as "joining"; simultaneous reads as "all already there."
|
||||
- **Stagger lines AFTER avatars are mostly settled** — line draw starts ~0.1-0.2s before last avatar settles for overlap.
|
||||
- **Idle breathing post-formation** — each avatar slightly out-of-phase. Holds the eye during climax dwell.
|
||||
- **❗ Climax dwell ≥1s** — after lines complete, hold for ≥1s so the formed network is readable.
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Timeline must be paused**: `gsap.timeline({ paused: true })`
|
||||
- **Registry key = `data-composition-id`**
|
||||
- **No CSS animation** on avatars or lines
|
||||
- **`will-change: transform, opacity`** on avatars
|
||||
- **SVG `pointer-events: none`** — decorative overlay
|
||||
- **`getTotalLength()` not needed for straight lines** — use `Math.hypot` for line length (cheaper, exact)
|
||||
- **Hub `z-index` > lines z-index** — explicit layering
|
||||
|
||||
## Combinations
|
||||
|
||||
- [counting-dynamic-scale.md](counting-dynamic-scale.md) — the hub IS a growing counter
|
||||
- [sine-wave-loop.md](sine-wave-loop.md) — avatar idle breathing pattern
|
||||
- [3d-text-depth-layers.md](3d-text-depth-layers.md) — hub label with depth layers
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — staggered spring entries + SVG dash draw
|
||||
- `/hyperframes-core` — composition wiring
|
||||
- `/hyperframes-cli` — `hyperframes lint`
|
||||
@@ -0,0 +1,246 @@
|
||||
---
|
||||
name: camera-cursor-tracking
|
||||
description: Two-phase virtual camera that locks viewport to a moving focal point with configurable initial positioning.
|
||||
metadata:
|
||||
tags: camera, tracking, viewport, two-phase, spring
|
||||
---
|
||||
|
||||
# Two-Phase Camera Cursor Tracking
|
||||
|
||||
Keeps a horizontally-growing element (e.g. a search bar with typing text, a long URL animating in) visible by switching between two camera modes.
|
||||
|
||||
## How It Works
|
||||
|
||||
Separate **World Space** (the full target element with all content) from **Screen Space** (the viewport). Two phases:
|
||||
|
||||
- **Phase 1 (Static)** — The world container sits at a fixed initial offset. Camera doesn't move. This anchors the viewer's eye to the composition before tracking begins.
|
||||
- **Phase 2 (Tracking)** — Activates when the focal point (cursor, highlight, last typed glyph) exceeds a target screen position (e.g. a configurable fraction `CURSOR_TARGET_FRACTION` of viewport width from the left). The world container translates leftward (`x: -<delta>`) keeping the focal point pinned at that screen position.
|
||||
|
||||
The offset math is **mathematically continuous** at the phase boundary — at the instant tracking starts, the world position equals what the static phase had. So the transition is seamless.
|
||||
|
||||
The piecewise form used in code is:
|
||||
|
||||
```
|
||||
finalWorldX = Math.min(INITIAL_OFFSET, trackingOffset)
|
||||
```
|
||||
|
||||
`INITIAL_OFFSET` is the static-phase value; `trackingOffset` is whatever shift keeps the focal point at `CURSOR_TARGET_FRACTION × viewportWidth`. While the focal point hasn't grown past the target screen X, `trackingOffset` exceeds `INITIAL_OFFSET` (it's a less-negative number) and `Math.min` returns the static value. Once the focal point would cross the target, `trackingOffset` overtakes `INITIAL_OFFSET` and tracking takes over.
|
||||
|
||||
## HTML
|
||||
|
||||
```html
|
||||
<div
|
||||
class="scene"
|
||||
id="tracking-scene"
|
||||
data-composition-id="tracking-scene"
|
||||
data-start="0"
|
||||
data-duration="5"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div class="viewport">
|
||||
<div class="world">
|
||||
<div class="search-bar">
|
||||
<span class="text" id="reveal-text">{phrase}</span><span class="cursor">|</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS (hero-frame layout)
|
||||
|
||||
```css
|
||||
.scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.viewport {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden; /* clip the world content */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding-left: VIEWPORT_PAD_LEFT; /* "left margin" — variation: left-aligned init */
|
||||
}
|
||||
|
||||
.world {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap; /* keep text on one line for camera-tracking */
|
||||
transform: translateX(0); /* GSAP will animate this */
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
font-family: {font};
|
||||
font-size: BAR_FONT_SIZE;
|
||||
font-weight: BAR_FONT_WEIGHT;
|
||||
color: {textColor};
|
||||
letter-spacing: BAR_LETTER_SPACING;
|
||||
}
|
||||
|
||||
.search-bar .text {
|
||||
/* Width grows as more characters reveal */
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.search-bar .cursor {
|
||||
display: inline-block;
|
||||
width: CURSOR_WIDTH;
|
||||
margin-left: CURSOR_GAP;
|
||||
background: {accentColor};
|
||||
height: CURSOR_HEIGHT_EM;
|
||||
vertical-align: bottom;
|
||||
/* No `animation: blink` CSS keyframe here — HF renders by seeking a paused
|
||||
timeline, and CSS animation clocks are NOT synced to that seek. A CSS
|
||||
blink will flicker non-deterministically. Drive cursor blink as a finite
|
||||
yoyo tween on the GSAP timeline instead — see GSAP Timeline section. */
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// Pre-measure target text width to compute tracking distance.
|
||||
// Build the timeline SYNCHRONOUSLY — see Critical Constraints for why
|
||||
// a fonts.ready gate causes worker-race flicker.
|
||||
const textEl = document.getElementById("reveal-text");
|
||||
const fullText = textEl.textContent;
|
||||
const targetCursorScreenX = CURSOR_TARGET_FRACTION * VIEWPORT_WIDTH;
|
||||
const fullWidth = textEl.scrollWidth; // total text width after full reveal
|
||||
const trackingDelta = Math.max(0, VIEWPORT_PAD_LEFT + fullWidth - targetCursorScreenX);
|
||||
|
||||
// Phase 1 — text reveals progressively; camera holds.
|
||||
// Reveal via max-width tween (no layout-property tweens on width/left/top).
|
||||
tl.fromTo(
|
||||
".search-bar .text",
|
||||
{ maxWidth: 0 },
|
||||
{
|
||||
maxWidth: fullWidth,
|
||||
duration: REVEAL_DUR,
|
||||
ease: "none", // linear typing rate
|
||||
},
|
||||
REVEAL_START,
|
||||
);
|
||||
|
||||
// Phase 2 — camera tracks. Begin BEFORE full reveal so the boundary feels
|
||||
// continuous (text is still revealing as camera starts moving). The
|
||||
// Math.min(INITIAL_OFFSET, trackingOffset) formulation makes the handoff
|
||||
// mathematically continuous; see How It Works.
|
||||
tl.to(
|
||||
".world",
|
||||
{
|
||||
x: -trackingDelta,
|
||||
duration: TRACK_DUR,
|
||||
ease: "power2.inOut",
|
||||
},
|
||||
TRACK_START,
|
||||
);
|
||||
|
||||
// Cursor blink — GSAP-driven (NEVER CSS @keyframes infinite, which doesn't
|
||||
// sync with HF's seek-by-frame). Finite yoyo, repeats computed from scene
|
||||
// length so blinks land deterministically across frames.
|
||||
const blinkRepeats = Math.ceil(SCENE_DURATION / BLINK_HALF_PERIOD) - 1;
|
||||
tl.to(
|
||||
".search-bar .cursor",
|
||||
{
|
||||
opacity: 0,
|
||||
duration: BLINK_HALF_PERIOD,
|
||||
ease: "steps(1)", // hard on/off, no fade
|
||||
yoyo: true,
|
||||
repeat: blinkRepeats,
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
window.__timelines["tracking-scene"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
### Variations
|
||||
|
||||
- **Centered → Center-Tracked**: set `.viewport { justify-content: center; padding: 0; }`. Camera tracks once the focal point crosses the midline (`CURSOR_TARGET_FRACTION = 0.5`).
|
||||
- **Left-Aligned → Right-Tracked**: as written above. Best when content exceeds viewport width from the start.
|
||||
- **Continuous typing driver**: replace the `maxWidth` tween with an `onUpdate` typing clock (`charsTyped = Math.floor(progress)`) plus per-frame `measureNodeWidth` to drive the cursor screen X. Required when the typed text is consumed elsewhere in the scene (e.g. by a parent strip's camera offset).
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
- **VIEWPORT_PAD_LEFT** — left-edge padding of the world inside the viewport (Phase 1 anchor X).
|
||||
- Range: 0 → ~10% of viewport width
|
||||
- Effects: 0 hugs the left edge; larger inset feels more like a centered hero frame
|
||||
- Constraints: must match the CSS `padding-left` on `.viewport` or the camera math drifts
|
||||
- **VIEWPORT_WIDTH** — the composition's `data-width` in CSS pixels.
|
||||
- Constraints: must equal the scene root's `data-width`; never tweened
|
||||
- **CURSOR_TARGET_FRACTION** — fraction of viewport width where the focal point locks during Phase 2.
|
||||
- Range: 0.5 (center-tracked) → 0.75 (right-leaning, more text visible behind cursor)
|
||||
- Effects: lower values leave less revealed text in frame; higher values delay tracking
|
||||
- **BAR_FONT_SIZE** — hero element font size.
|
||||
- Range: ~8-12% of viewport height; below ~6% reads as a UI widget rather than a cinematic element
|
||||
- **BAR_FONT_WEIGHT** — weight of the search-bar text.
|
||||
- Range: discrete; 400 for neutral demo text, 700 for hero / headline framing
|
||||
- **BAR_LETTER_SPACING** — `letter-spacing` for the bar text.
|
||||
- Range: slight negative (tighter, more cinematic) → 0 (default)
|
||||
- **CURSOR_WIDTH** — visual cursor stroke width in CSS pixels.
|
||||
- Range: ~4-10 px at 1080p; thinner reads as typed text, thicker reads as a block caret
|
||||
- **CURSOR_GAP** — `margin-left` between text and cursor.
|
||||
- Range: a few px of breathing room; do not exceed the cursor width or it visually detaches
|
||||
- **CURSOR_HEIGHT_EM** — cursor height as an em multiple of the font.
|
||||
- Range: 0.85-1.0; matches the visual height of the typed glyphs
|
||||
- **REVEAL_START** — when Phase 1 typing begins.
|
||||
- Constraints: typically 0; if preceded by another phase, ≥ that phase's end + small buffer
|
||||
- **REVEAL_DUR** — duration of the Phase 1 reveal tween.
|
||||
- Range: scale with character count (target an average per-character cadence in the 0.05-0.15s range)
|
||||
- Constraints: must end before `SCENE_DURATION` and ideally overlap slightly with the tracking phase
|
||||
- **TRACK_START** — when Phase 2 camera motion begins.
|
||||
- Range: usually before reveal completes so the handoff feels continuous; can equal `REVEAL_START` if the focal point is already past the target at t=0
|
||||
- Constraints: `TRACK_START < REVEAL_START + REVEAL_DUR` for a smooth crossfade
|
||||
- **TRACK_DUR** — duration of the camera pan.
|
||||
- Range: 0.8-2.0s; under 0.5s reads as a snap, over 2.5s drags
|
||||
- **SCENE_DURATION** — must match the scene root's `data-duration`.
|
||||
- Constraints: feeds the blink repeat count; mismatch causes blinks to truncate or run past the end
|
||||
- **BLINK_HALF_PERIOD** — half-period of the cursor blink (one on-state OR one off-state).
|
||||
- Range: 0.2-0.4s; 0.3s reads as a natural caret blink
|
||||
- Constraints: derived value `Math.ceil(SCENE_DURATION / BLINK_HALF_PERIOD) - 1` must be ≥ 0
|
||||
- **Ease choices** — discrete:
|
||||
- Camera pan: `power2.inOut` or `power3.inOut` for cinematic settle; avoid `back.out` (overshoot reads as UI bounce, not camera)
|
||||
- Reveal: `"none"` for linear typing; any easing distorts the per-keystroke cadence
|
||||
- Blink: `"steps(1)"` for hard on/off; any easing fades the cursor and breaks the caret feel
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Measure with `getBoundingClientRect()` / probe nodes**, not by character count × font-size. Proportional fonts have variable glyph widths.
|
||||
- **`white-space: nowrap`** on the world — text must stay on one line for camera math to work
|
||||
- **Pre-allocate the world width** by setting `maxWidth` at full target width — prevents layout shift mid-tween
|
||||
- **Eased camera** (`power2.inOut` / `power3.inOut`), not linear — natural pan feel
|
||||
- **Spring-like via easing**, not via stiffness/damping params — GSAP doesn't have a built-in spring, but `back.out(${BOUNCE_FACTOR})` or `power4.out` approximate the settling feel
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Build the timeline SYNCHRONOUSLY, no fonts.ready gate** — HF renders frames in parallel workers, each a fresh browser. If you wrap the timeline build in `document.fonts.ready.then(...)`, some workers will seek frames BEFORE the Promise resolves and find no timeline registered → those frames render at CSS initial state (e.g. `max-width: 0` ⇒ empty text), other workers render correctly → visible flicker between empty and filled. Register `window.__timelines[id] = tl` at script-parse time, even if fonts haven't loaded yet — the camera math can tolerate a few percent width error from fallback-font measurement, but worker-race flicker is unacceptable.
|
||||
- **If precise post-font measurement matters**, re-measure inside the tween's `onUpdate` (still deterministic per-frame seek), not via a Promise gate. Or set `font-display: block` on the @font-face to force the browser to wait for the font before painting any text.
|
||||
- **Timeline must be paused**: `gsap.timeline({ paused: true })`. Never `tl.play()`
|
||||
- **Registry key = `data-composition-id`**: `window.__timelines["tracking-scene"]` must match scene root
|
||||
- **Continuous math at phase boundary**: the world's `x` at the moment tracking starts must equal the static-phase offset. The `Math.min(INITIAL_OFFSET, trackingOffset)` formulation guarantees this; do NOT switch to a hard `if (typingProgress > threshold)` branch or the camera will visibly jump.
|
||||
- **Inline cursor, not absolutely positioned**: cursor should be a sibling of the text (inline-block) so it follows text flow naturally — absolute positioning misaligns with the camera math
|
||||
- **`overflow: hidden` on `.viewport`**: clip the world's left edge as it pans off-screen
|
||||
- **Cursor blink via GSAP, NOT CSS `@keyframes ... infinite`** — HF renders by seeking the paused timeline; CSS animation clocks are NOT synchronized with that seek, so any CSS-driven blink will flicker non-deterministically across frames. Always drive blink as a finite yoyo tween on the paused GSAP timeline (repeat count computed from scene length).
|
||||
|
||||
## Combinations
|
||||
|
||||
- [context-sensitive-cursor.md](context-sensitive-cursor.md) — change cursor color/style per text segment during typing
|
||||
- [discrete-text-sequence.md](discrete-text-sequence.md) — non-linear text reveals that pair with this camera
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — timeline + tween API
|
||||
- `/hyperframes-core` — composition wiring + `data-*` attributes
|
||||
- `/hyperframes-cli` — `hyperframes lint` to validate the registry key + duration
|
||||
@@ -0,0 +1,267 @@
|
||||
---
|
||||
name: card-morph-anchor
|
||||
description: Container morphs dimensions and border-radius between shots, serving as a visual transition anchor.
|
||||
metadata:
|
||||
tags: morph, anchor, transition, border-radius, container, shape
|
||||
---
|
||||
|
||||
# Card Morph Anchor
|
||||
|
||||
A container smoothly transforms its width, height, border-radius, and (optionally) background between two visual states. The morph itself **IS the shot transition** — no separate transition effect needed. The viewer's eye tracks the morphing container as the anchor between shots.
|
||||
|
||||
## How It Works
|
||||
|
||||
A single GSAP tween animates multiple container properties simultaneously (width / height / border-radius / background). At the same time:
|
||||
|
||||
1. **Old content** fades out during the first ~40% of the morph
|
||||
2. **New content** fades in during the last ~40% of the morph
|
||||
3. **Optional final fade** — the morph container itself fades to 0, revealing the actual next-shot element rendered behind it
|
||||
|
||||
The persistent container provides visual continuity even as content and shape change.
|
||||
|
||||
## HTML
|
||||
|
||||
```html
|
||||
<div
|
||||
class="scene"
|
||||
id="morph-scene"
|
||||
data-composition-id="morph-scene"
|
||||
data-start="0"
|
||||
data-duration="4"
|
||||
data-track-index="0"
|
||||
>
|
||||
<!-- The persistent morph container -->
|
||||
<div class="morph-card">
|
||||
<div class="content-old">
|
||||
<h2>{shotOneHeadline}</h2>
|
||||
<p>{shotOneSubcopy}</p>
|
||||
</div>
|
||||
<div class="content-new">
|
||||
<img src="{shotTwoIcon}" alt="logo" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Optional: actual next-shot element behind the morph -->
|
||||
<div class="next-shot-anchor">
|
||||
<img src="{nextShotAnchor}" alt="anchor" />
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS (hero-frame layout)
|
||||
|
||||
Card starts as a wide rectangle (shot 1 state). All properties present from the start; only opacities differ:
|
||||
|
||||
```css
|
||||
.scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.morph-card {
|
||||
position: relative;
|
||||
width: {SHOT_ONE_W}px;
|
||||
height: {SHOT_ONE_H}px;
|
||||
border-radius: {SHOT_ONE_RADIUS}px;
|
||||
background: {surfaceShotOne};
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.content-old,
|
||||
.content-new {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.content-old {
|
||||
opacity: 1;
|
||||
}
|
||||
.content-new {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.next-shot-anchor {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0; /* GSAP fades this in as morph card fades out */
|
||||
/* Use DOM ORDER for stacking — render .next-shot-anchor BEFORE .morph-card
|
||||
in markup so the morph card is naturally on top. Do NOT use z-index: -1
|
||||
and then snap it positive mid-fade — that causes a visible pop. */
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// Named constants — assign in your example only. See "How to Choose Values".
|
||||
const HOLD_BEAT; // s — pre-morph dwell on shot 1
|
||||
const MORPH_START; // s — usually = HOLD_BEAT
|
||||
const MORPH_DUR; // s — full container morph length
|
||||
const SHOT_TWO_W; // px — final container width
|
||||
const SHOT_TWO_H; // px — final container height
|
||||
const SHOT_TWO_RADIUS; // px — ≤ min(SHOT_TWO_W, SHOT_TWO_H) / 2
|
||||
const OLD_FADE_FRAC; // 0..0.5 — fraction of MORPH_DUR for old content fade
|
||||
const NEW_FADE_FRAC; // 0..0.5 — fraction of MORPH_DUR for new content fade
|
||||
const FINAL_FADE_FRAC; // 0..0.3 — optional tail fade for handoff
|
||||
// {surfaceShotTwo} is a CSS background token (solid or gradient).
|
||||
|
||||
// Hold shot 1 — let the viewer register the wide banner before morphing.
|
||||
|
||||
// Phase 1 — Morph container properties simultaneously
|
||||
tl.to(
|
||||
".morph-card",
|
||||
{
|
||||
width: SHOT_TWO_W,
|
||||
height: SHOT_TWO_H,
|
||||
borderRadius: SHOT_TWO_RADIUS,
|
||||
background: "{surfaceShotTwo}",
|
||||
duration: MORPH_DUR,
|
||||
ease: "power2.inOut",
|
||||
},
|
||||
MORPH_START,
|
||||
);
|
||||
|
||||
// Phase 2 — Old content fades during the FIRST OLD_FADE_FRAC of the morph
|
||||
tl.to(
|
||||
".content-old",
|
||||
{
|
||||
opacity: 0,
|
||||
duration: MORPH_DUR * OLD_FADE_FRAC,
|
||||
ease: "power1.in",
|
||||
},
|
||||
MORPH_START,
|
||||
);
|
||||
|
||||
// Phase 3 — New content fades in during the LAST NEW_FADE_FRAC of the morph
|
||||
tl.to(
|
||||
".content-new",
|
||||
{
|
||||
opacity: 1,
|
||||
duration: MORPH_DUR * NEW_FADE_FRAC,
|
||||
ease: "power1.out",
|
||||
},
|
||||
MORPH_START + MORPH_DUR * (1 - NEW_FADE_FRAC),
|
||||
);
|
||||
|
||||
// Optional Phase 4 — Final fade: morph container disappears at the very end,
|
||||
// revealing the actual next-shot element behind it.
|
||||
tl.to(
|
||||
".morph-card",
|
||||
{
|
||||
opacity: 0,
|
||||
duration: MORPH_DUR * FINAL_FADE_FRAC,
|
||||
ease: "power1.in",
|
||||
},
|
||||
MORPH_START + MORPH_DUR * (1 - FINAL_FADE_FRAC),
|
||||
);
|
||||
|
||||
window.__timelines["morph-scene"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
## Key Properties to Morph
|
||||
|
||||
| Property | Shape of change | Visual effect |
|
||||
| ------------------ | -------------------------------------------------------------- | ---------------------------- |
|
||||
| `width` / `height` | `SHOT_ONE_W × SHOT_ONE_H` → `SHOT_TWO_W × SHOT_TWO_H` | wide card shrinks to an icon |
|
||||
| `borderRadius` | `SHOT_ONE_RADIUS` → `SHOT_TWO_RADIUS` (≤ half of smaller side) | rectangle becomes a circle |
|
||||
| `background` | `{surfaceShotOne}` → `{surfaceShotTwo}` (solid or gradient) | container identity shifts |
|
||||
| `boxShadow` | base shadow → accent glow token | emphasis changes |
|
||||
|
||||
GSAP tweens all of these simultaneously when included in one `tl.to(...)` call.
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
- **HOLD_BEAT** — pre-morph dwell so the viewer registers shot 1 before it changes
|
||||
- Range: 0.6-1.5 s
|
||||
- Effects: low end feels rushed / glitchy; high end stalls pacing
|
||||
- Constraints: must be ≥ shot 1's content entry settle time
|
||||
- **MORPH_START** — when the container morph begins
|
||||
- Range: equal to `HOLD_BEAT` in the canonical pattern
|
||||
- Constraints: must be > any shot-1 entry tween end
|
||||
- **MORPH_DUR** — full length of the simultaneous container morph
|
||||
- Range: 0.6-1.2 s
|
||||
- Effects: low end reads as a snap; high end loses momentum
|
||||
- Constraints: short morphs (<0.5s) cannot fit both old-fade and new-fade
|
||||
- **SHOT_TWO_W / SHOT_TWO_H** — final container dimensions
|
||||
- Range: 80-400 px when handing off to an icon-sized anchor
|
||||
- Constraints: if handing off (`.next-shot-anchor`), MUST match the anchor's dimensions exactly to avoid a visible pop
|
||||
- **SHOT_TWO_RADIUS** — final corner radius (use to read as circle / pill / soft-rect)
|
||||
- Range: 0 to `min(SHOT_TWO_W, SHOT_TWO_H) / 2`
|
||||
- Effects: half-of-smaller-side = perfect circle; smaller = soft rect
|
||||
- Constraints: > half is visually clamped — wastes the tween
|
||||
- **OLD_FADE_FRAC** — fraction of `MORPH_DUR` over which shot-1 content fades out, starting at `MORPH_START`
|
||||
- Range: 0.3-0.5
|
||||
- Effects: low end clips shot 1 too early; high end overlaps with shot 2 content
|
||||
- Constraints: `OLD_FADE_FRAC + NEW_FADE_FRAC ≤ 1` (gap between is the "shape-only" moment)
|
||||
- **NEW_FADE_FRAC** — fraction of `MORPH_DUR` over which shot-2 content fades in, ending at `MORPH_START + MORPH_DUR`
|
||||
- Range: 0.3-0.5
|
||||
- Effects: symmetric to OLD_FADE_FRAC
|
||||
- **FINAL_FADE_FRAC** — optional tail fraction during which the morph container itself fades to 0 for handoff
|
||||
- Range: 0 (no handoff) or 0.1-0.2
|
||||
- Constraints: only use when `.next-shot-anchor` matches the morph's final visual exactly
|
||||
- **Ease family** — discrete choice
|
||||
- Options: `power2.inOut` (canonical, balanced), `power3.inOut` (snappier), `expo.inOut` (most cinematic but can feel sluggish at low durations)
|
||||
- Avoid `back.out` / `elastic.out` on the morph itself — overshoot fights the dimensional change
|
||||
|
||||
CSS-side placeholders (`SHOT_ONE_W`, `SHOT_ONE_H`, `SHOT_ONE_RADIUS`, `{surfaceShotOne}`, `{surfaceShotTwo}`) take real values in the example. Pick `{surfaceShotOne}` and `{surfaceShotTwo}` so the gradient/solid stops counts match (GSAP can interpolate background gradients only when stop counts agree).
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **All target properties in one tween** — they share a single ease and duration so they morph in lockstep
|
||||
- **Old content fades early, new content fades late** — the container shape change happens between, providing a natural "blink" moment
|
||||
- **Final fade is optional** — use it when the next shot has a real anchor element to hand off to (e.g. avatar that the icon morphed into "is")
|
||||
- **Same easing for shape and crossfade** — avoid mixing `power2.inOut` morph with `bounce.out` content, looks unsynchronized
|
||||
- **❗ If you use `.next-shot-anchor` for handoff, its visuals must be pixel-identical to `.morph-card`'s final state** — same `width` / `height`, same `border-radius`, same `background`, same `box-shadow`, same internal icon dimensions. Any visual delta between the two = visible pop during the crossfade. If you can't match exactly, **drop the handoff** and just hold the morph card at its final state (add a breath if needed for life).
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **`overflow: hidden`** on the morph container — content must clip during shape change, otherwise content overflows the morphing border radius
|
||||
- **Hold a beat before morphing** — let the viewer register shot 1's content before morphing; instant morph reads as glitchy
|
||||
- **Timeline must be paused**: `gsap.timeline({ paused: true })`. Never `tl.play()`
|
||||
- **Registry key = `data-composition-id`**: `window.__timelines["morph-scene"]` must match scene root
|
||||
- **Use `background` tween, not `background-color`**: gradients need `background` (GSAP supports gradient interpolation when targets are gradients with same number of stops). For solid → solid, `backgroundColor` works.
|
||||
- **`borderRadius` should be ≤ half the smaller dimension** at end state — otherwise the radius is visually clamped and the morph looks abrupt at the boundary
|
||||
- **❗ Don't snap `z-index` mid-fade** — if you need `.next-shot-anchor` to appear from behind the morph card, use **DOM order** (render `.next-shot-anchor` BEFORE `.morph-card` so the morph card is naturally on top), then crossfade their opacities. A `tl.set({ zIndex: ... })` call during an active opacity tween causes a visible flicker as the stacking order flips before the opacity transition finishes.
|
||||
|
||||
## Variation: Morphing to a target element's position
|
||||
|
||||
When shot 2 isn't centered (e.g. the morph card "lands" on a specific icon in a dock, sidebar, or grid), compute the target `top` / `left` from the **target element's element-position**, not its visual center. Common mistake: subtracting `height/2` to get center, then applying that to the morph-card's `top` — but if `.morph-card` uses absolute positioning with `top` + `margin: 0` (no transform-centering), `top` represents the **element top edge**, not the center.
|
||||
|
||||
Math template (example: morph card lands on icon at bottom dock):
|
||||
|
||||
```
|
||||
target_element_top = viewport_height − dock_bottom_offset − dock_padding_y − icon_height
|
||||
= 1080 − 60 − 22 − 110 = 888 px
|
||||
```
|
||||
|
||||
Then tween `.morph-card { top: 888 }` so its element-top aligns with the target icon's element-top. If you mistakenly tween to `888 + icon_height/2 = 943` you'll land below; tweening to a "center" value like `top: 933` (off-by-arithmetic) will be even worse.
|
||||
|
||||
Always **measure the target element with `getBoundingClientRect()`** before the timeline starts, and use those numbers — don't hand-compute from CSS values, since paddings, borders, and parent transforms compound.
|
||||
|
||||
## Combinations
|
||||
|
||||
- [scale-swap-transition.md](scale-swap-transition.md) — simpler morph without dimension change (just scale + content swap)
|
||||
- [sine-wave-loop.md](sine-wave-loop.md) — gentle breathing on the final state (e.g. final small circular icon idles with a breath)
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — timeline + multi-property tween reference
|
||||
- `/hyperframes-core` — composition wiring, `data-*` attributes
|
||||
- `/hyperframes-cli` — `hyperframes lint` to verify scene structure
|
||||
@@ -0,0 +1,227 @@
|
||||
---
|
||||
name: center-outward-expansion
|
||||
description: Elements start clustered at screen center and expand outward to their final positions, driven by a shared progress value.
|
||||
metadata:
|
||||
tags: expansion, scatter, center, reveal, layout, sync, burst
|
||||
---
|
||||
|
||||
# Center-Outward Expansion
|
||||
|
||||
Elements begin at a shared center point and radiate outward to their final positions. The expansion can be the entry beat itself, or **driven by another animation's progress** (e.g. a counting number growing) for coordinated motion.
|
||||
|
||||
## How It Works
|
||||
|
||||
Each element has a `targetX/Y` (its final layout position) and a shared `centerX/Y`. A `progress` value (0→1) interpolates each element between center and target:
|
||||
|
||||
```js
|
||||
const x = centerX + (targetX - centerX) * progress;
|
||||
const y = centerY + (targetY - centerY) * progress;
|
||||
```
|
||||
|
||||
When `progress = 0` all elements overlap at the center; when `progress = 1` they're at their final spots.
|
||||
|
||||
## HTML
|
||||
|
||||
```html
|
||||
<div
|
||||
class="scene"
|
||||
data-composition-id="burst-scene"
|
||||
data-start="0"
|
||||
data-duration="3"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div class="burst-wrap">
|
||||
<div class="burst-item" data-target-x="-360" data-target-y="-180">{itemA}</div>
|
||||
<div class="burst-item" data-target-x="360" data-target-y="-180">{itemB}</div>
|
||||
<div class="burst-item" data-target-x="-360" data-target-y="180">{itemC}</div>
|
||||
<div class="burst-item" data-target-x="360" data-target-y="180">{itemD}</div>
|
||||
<div class="burst-item" data-target-x="0" data-target-y="-360">{itemE}</div>
|
||||
<div class="burst-item" data-target-x="0" data-target-y="360">{itemF}</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS
|
||||
|
||||
```css
|
||||
.scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: {bgColor};
|
||||
}
|
||||
.burst-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.burst-item {
|
||||
position: absolute;
|
||||
/* Items start at the wrap center via the absolute + 50% trick.
|
||||
We tween translate offsets via GSAP, not left/top. */
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
width: {itemSize};
|
||||
height: {itemSize};
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: {itemBgColor};
|
||||
border-radius: 28px;
|
||||
font-family: {font};
|
||||
font-weight: 900;
|
||||
font-size: 96px;
|
||||
color: {textColor};
|
||||
will-change: transform;
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
const items = document.querySelectorAll(".burst-item");
|
||||
|
||||
// Each element gets its own from→to that lerps center (translate(-50%, -50%))
|
||||
// → target offset. xPercent/yPercent bakes the self-centering; x/y animates
|
||||
// toward the target.
|
||||
items.forEach((el, i) => {
|
||||
const targetX = Number(el.dataset.targetX);
|
||||
const targetY = Number(el.dataset.targetY);
|
||||
tl.fromTo(
|
||||
el,
|
||||
{ xPercent: -50, yPercent: -50, x: 0, y: 0, scale: 0.6, opacity: 0 },
|
||||
{
|
||||
x: targetX,
|
||||
y: targetY,
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
duration: EXPAND_DUR,
|
||||
ease: EXPAND_EASE,
|
||||
},
|
||||
i * STAGGER + ENTRY_AT, // stagger; ENTRY_AT offsets the burst beat
|
||||
);
|
||||
});
|
||||
|
||||
window.__timelines["burst-scene"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
- **ITEM_COUNT** — number of elements in the burst
|
||||
- Range: 3–8
|
||||
- Effects: 3 = sparse; 8 = busy. > 8 causes visual chaos where cards overlap mid-expansion
|
||||
- Constraints: at low counts, prefer wider angular spread (target positions further apart)
|
||||
|
||||
- **EXPAND_DUR** — duration of each item's center → target tween
|
||||
- Range: 1.0–1.8 s
|
||||
- Effects: shorter = snappy burst; longer = floats outward
|
||||
- Constraints: if driven by a counter, must equal the counter's duration (chord)
|
||||
|
||||
- **EXPAND_EASE** — shared ease across all items
|
||||
- Discrete choice: `power2.out`, `power3.out`, `expo.out`
|
||||
- Selection: `power3.out` is the default — fling out then settle. `power2.out` is gentler. `expo.out` makes them stop dramatically. Avoid `in` easings (they read as items being sucked back in mid-air).
|
||||
- Constraint: if driven by another animation, must be identical to the driver's ease
|
||||
|
||||
- **STAGGER** — gap between successive items' start times
|
||||
- Range: 0.04–0.08 s
|
||||
- Effects: < 0.04 = simultaneous chord; > 0.08 feels lazy / arpeggiated
|
||||
- Constraints: ITEM_COUNT × STAGGER must be < EXPAND_DUR or the last items still moving when others have landed reads as ragged
|
||||
|
||||
- **ENTRY_AT** — offset applied to the whole burst start
|
||||
- Range: 0 – 0.5 s
|
||||
- Effects: > 0 gives a beat of compositional quiet before the burst
|
||||
|
||||
- **START_PROGRESS** — fraction of the center→target path where items begin (for partially-spread variant)
|
||||
- Range: 0 (exact center) – 0.5
|
||||
- Effects: 0 = full cluster, dramatic spread; 0.3 = avoids initial pile-up at center
|
||||
|
||||
## Variations
|
||||
|
||||
### Synced expansion (driven by a counter)
|
||||
|
||||
If the burst should mirror a counting animation's progress:
|
||||
|
||||
```js
|
||||
// Counter tween defines a state.value 0 → TARGET over COUNT_DUR
|
||||
const counterState = { value: 0 };
|
||||
const burstState = { p: 0 };
|
||||
|
||||
// Shared tween — same duration, same ease — visually a "chord"
|
||||
tl.to(
|
||||
counterState,
|
||||
{
|
||||
value: COUNT_TARGET,
|
||||
duration: COUNT_DUR,
|
||||
ease: COUNT_EASE,
|
||||
onUpdate: () => (counterEl.textContent = Math.round(counterState.value).toLocaleString()),
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
tl.to(
|
||||
burstState,
|
||||
{
|
||||
p: 1,
|
||||
duration: COUNT_DUR,
|
||||
ease: COUNT_EASE,
|
||||
onUpdate: () =>
|
||||
items.forEach((el) => {
|
||||
const tx = Number(el.dataset.targetX) * burstState.p;
|
||||
const ty = Number(el.dataset.targetY) * burstState.p;
|
||||
el.style.transform = `translate(-50%, -50%) translate(${tx}px, ${ty}px)`;
|
||||
}),
|
||||
},
|
||||
0,
|
||||
);
|
||||
```
|
||||
|
||||
### Starting partially-spread
|
||||
|
||||
To avoid the initial clustered mess (6+ elements stacked at center), start at `START_PROGRESS`:
|
||||
|
||||
```js
|
||||
{ x: targetX * START_PROGRESS, y: targetY * START_PROGRESS, scale: 0.4, opacity: 0 }
|
||||
```
|
||||
|
||||
### Idle micro-float at final position
|
||||
|
||||
Pair with `sine-wave-loop` after expansion lands — keeps elements alive instead of frozen.
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Driver vs driven** — if the burst stands on its own, use a per-item stagger; if it shadows another animation (counter, audio beat), share the same eased progress so they read as one beat
|
||||
- **Stagger inside the 0.04-0.08 s band** — too tight and the cluster never separates visually, too loose and the burst feels lazy
|
||||
- **Out-easing for the expansion** — out-easing makes items "fling" out then settle. In-easing looks like they're sucked back in mid-air
|
||||
- **Element count: 3-8** — fewer feels empty, more causes visual chaos at the center where cards overlap mid-expansion
|
||||
- **❗ Don't put a label below the burst as the "real headline"** — if you do, the eye snaps to the label and ignores the burst. The burst IS the beat. If a label is needed, use big block-caps and reveal it post-burst, in the same stacked layout.
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Timeline must be paused**: `gsap.timeline({ paused: true })`
|
||||
- **Registry key = `data-composition-id`**
|
||||
- **Use translate, not left/top** — translating composes cleanly with the centering `translate(-50%, -50%)` trick; mutating `left`/`top` fights the centering and causes pixel jitter
|
||||
- **`will-change: transform`** on burst items — many simultaneous transforms benefit from compositor hints
|
||||
- **No `position: absolute` parents inside `burst-wrap` other than items themselves** — sibling absolute elements would steal the centered baseline
|
||||
|
||||
## Combinations
|
||||
|
||||
- [counting-dynamic-scale.md](counting-dynamic-scale.md) — counter peak drives the burst peak (chord)
|
||||
- [sine-wave-loop.md](sine-wave-loop.md) — idle motion after the burst lands
|
||||
- [card-morph-anchor.md](card-morph-anchor.md) — burst out of a morphed card
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — timeline + stagger
|
||||
- `/hyperframes-core` — composition wiring
|
||||
- `/hyperframes-cli` — `hyperframes lint`
|
||||
@@ -0,0 +1,257 @@
|
||||
---
|
||||
name: context-sensitive-cursor
|
||||
description: Cursor color and styling that adapt to the current text segment being typed — accent color on highlights, dim on placeholders, etc.
|
||||
metadata:
|
||||
tags: cursor, color, context, typewriter, styling, segment
|
||||
---
|
||||
|
||||
# Context-Sensitive Cursor
|
||||
|
||||
In a typewriter sequence, the cursor's color (and optionally height/blink rate) matches the **active text segment**. If the typewriter is currently typing a brand name, the cursor is the brand accent color; on a placeholder, it dims to gray. Enhances visual cohesion vs a single fixed cursor color across all text states.
|
||||
|
||||
## How It Works
|
||||
|
||||
The text is authored as a SEQUENCE of `{text, t, segment}` entries where `segment` is a string identifier ('main' / 'highlight' / 'brand' / 'success'). The driver tween's onUpdate determines the current segment based on `time`, then sets the cursor's CSS color (and optionally other props) to match that segment's palette.
|
||||
|
||||
## HTML
|
||||
|
||||
```html
|
||||
<div
|
||||
class="scene"
|
||||
id="cursor-scene"
|
||||
data-composition-id="cursor-scene"
|
||||
data-start="0"
|
||||
data-duration="{DURATION}"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div class="terminal">
|
||||
<div class="prompt">$</div>
|
||||
<div class="text-wrap">
|
||||
<span class="text" id="text"></span><span class="cursor" id="cursor">_</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS
|
||||
|
||||
Placeholders: `{monoFont}` is the project's monospace stack (proportional fonts cause cursor drift mid-segment); `{bgColor}` is the dark backdrop; `{textColor}` is the readable foreground; `{promptColor}` is the segment-default color for the leading prompt glyph.
|
||||
|
||||
```css
|
||||
.scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: {bgColor};
|
||||
font-family: {monoFont};
|
||||
}
|
||||
.terminal {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 24px;
|
||||
font-size: 72px;
|
||||
font-weight: 800;
|
||||
color: {textColor};
|
||||
white-space: pre;
|
||||
}
|
||||
.prompt {
|
||||
color: {promptColor};
|
||||
}
|
||||
.text-wrap {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
min-width: 1200px;
|
||||
}
|
||||
.text {
|
||||
color: {textColor};
|
||||
white-space: pre;
|
||||
}
|
||||
/* Cursor highlights based on active segment via per-frame background swap */
|
||||
.cursor {
|
||||
display: inline-block;
|
||||
width: {cursorWidth}px;
|
||||
height: {cursorHeight}px;
|
||||
background: {textColor}; /* default — overridden per segment in onUpdate */
|
||||
margin-left: {cursorGap}px;
|
||||
vertical-align: {cursorBaselineFix}px;
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// Sequence with per-entry segment label.
|
||||
// Each entry: { t: absoluteSeconds, text: cumulative visible string, segment: paletteKey, color: hex }.
|
||||
// As the driver crosses each `t`, the cursor color swaps to that segment's accent —
|
||||
// viewer's eye locks onto the keyword being typed.
|
||||
// Shape: a monotonic timeline of N entries where adjacent entries usually share text-prefix
|
||||
// but may differ in `segment` (which is what makes the cursor color shift mid-line).
|
||||
const SEQUENCE = [
|
||||
{ t: 0, text: "", segment: "main", color: "{mainColor}" },
|
||||
{ t: T_LEADIN_END, text: "{leadInChunk}", segment: "main", color: "{mainColor}" },
|
||||
{ t: T_BRAND_IN, text: "{leadInBrandPrefix}", segment: "brand", color: "{brandColor}" }, // brand segment starts
|
||||
{ t: T_BRAND_OUT, text: "{leadInBrandFull}", segment: "main", color: "{mainColor}" }, // back to main
|
||||
{ t: T_CMD_IN, text: "{leadInCmdPrefix}", segment: "cmd", color: "{cmdColor}" }, // command segment
|
||||
{ t: T_BRAND_2, text: "{leadInCmdBrand}", segment: "brand", color: "{brandColor}" }, // brand again (e.g. filename)
|
||||
{ t: T_SUCCESS, text: "{leadInDone}", segment: "success", color: "{successColor}" }, // completion mark
|
||||
];
|
||||
|
||||
function entryAt(time) {
|
||||
for (let i = SEQUENCE.length - 1; i >= 0; i--) {
|
||||
if (time >= SEQUENCE[i].t) return SEQUENCE[i];
|
||||
}
|
||||
return SEQUENCE[0];
|
||||
}
|
||||
|
||||
const textEl = document.getElementById("text");
|
||||
const cursorEl = document.getElementById("cursor");
|
||||
|
||||
// Discrete state driver — writes text + cursor color
|
||||
const driver = { t: 0 };
|
||||
tl.to(
|
||||
driver,
|
||||
{
|
||||
t: DURATION,
|
||||
duration: DURATION,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
const entry = entryAt(driver.t);
|
||||
textEl.textContent = entry.text;
|
||||
cursorEl.style.background = entry.color;
|
||||
},
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
// Deterministic blink via sin (NOT CSS animation).
|
||||
// Phase sweep = (2π) × BLINK_CYCLES_PER_SCENE drives a square wave at sign(sin(p)).
|
||||
const blink = { p: 0 };
|
||||
tl.to(
|
||||
blink,
|
||||
{
|
||||
p: Math.PI * 2 * BLINK_CYCLES_PER_SCENE,
|
||||
duration: DURATION,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
cursorEl.style.opacity = Math.sin(blink.p) > 0 ? "1" : "0";
|
||||
},
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
window.__timelines["cursor-scene"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
### Non-blinking during active typing
|
||||
|
||||
When letters are being added (driver moved forward in the last `TYPING_GRACE` seconds), suppress blink — cursor stays solid. When no typing activity (`driver.t - lastChangeTime > TYPING_GRACE`), resume blink.
|
||||
|
||||
```js
|
||||
let lastChangeTime = 0,
|
||||
lastText = "";
|
||||
// In onUpdate:
|
||||
if (entry.text !== lastText) {
|
||||
lastChangeTime = driver.t;
|
||||
lastText = entry.text;
|
||||
}
|
||||
const isTyping = driver.t - lastChangeTime < TYPING_GRACE;
|
||||
cursorEl.style.opacity = isTyping ? "1" : Math.sin(blink.p) > 0 ? "1" : "0";
|
||||
```
|
||||
|
||||
### Cursor HEIGHT shifts on segment
|
||||
|
||||
Larger cursor on brand segment for emphasis (`cursorHeightEmphasis > cursorHeight`):
|
||||
|
||||
```js
|
||||
cursorEl.style.height =
|
||||
entry.segment === "brand" ? `${cursorHeightEmphasis}px` : `${cursorHeight}px`;
|
||||
```
|
||||
|
||||
### Cursor reverses contrast on dark text
|
||||
|
||||
If a segment is rendered DARK text on light bg, cursor should swap to dark too. Manage via `entry.color` as the SOURCE OF TRUTH and read from there.
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Cursor color shifts make brand moments POP** — eye lands on the brand name because the cursor color shifts to brand accent. Without it, cursor is visual noise.
|
||||
- **`background` property on the cursor div** — NOT `color` (cursor is a colored block, not a glyph)
|
||||
- **Deterministic blink via sin** — never CSS `@keyframes blink`. HF seek will desync.
|
||||
- **Cursor `display: inline-block`** — `display: inline` ignores width/height.
|
||||
- **`vertical-align: -8px`** (or similar) — visually anchor cursor to text baseline, not full line-height.
|
||||
- **`white-space: pre`** on text and parent — preserve trailing spaces so cursor sits at end of segment, not after collapsed space.
|
||||
- **Color palette aligned with brand system** — 3-4 colors max for segments (main / brand / cmd / success). More and the segmentation reads as random.
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
- **DURATION** — total scene length in seconds
|
||||
- Range: 4-8 s for a single typed line; longer if the line is long
|
||||
- Effects: too short truncates the typing; too long leaves a dead tail after the success state
|
||||
- Constraints: must be `≥ SEQUENCE[last].t + (closing dwell)`
|
||||
- Reference: see the corresponding blueprint's example HTML
|
||||
|
||||
- **SEQUENCE entry `t` values** — absolute seconds where each new visible text + segment kicks in
|
||||
- Range: monotonically increasing; spacing 0.2-0.5 s between micro-additions (per-word or per-token), longer between segment swaps
|
||||
- Effects: too-tight spacing collapses the typing feel into a slideshow; too-loose drags
|
||||
- Constraints: ordered ascending; entries do not need uniform spacing — slow down on highlights
|
||||
- Reference: see the corresponding blueprint's example HTML
|
||||
|
||||
- **Segment palette: mainColor / brandColor / cmdColor / successColor** — the cursor-fill swatches
|
||||
- Range: 3-4 discrete colors max; each should be distinguishable at small cursor width
|
||||
- Effects: too many segments and the swaps read as random; too few and the brand moment loses pop
|
||||
- Constraints: `brandColor` and `successColor` may be similar in hue but should differ in saturation/luminance so a brand→success transition is visible
|
||||
- Reference: see the corresponding blueprint's example HTML
|
||||
|
||||
- **cursorWidth / cursorHeight / cursorGap / cursorBaselineFix** — cursor block geometry
|
||||
- Range: cursorWidth 8-24 px; cursorHeight ≈ 0.85-1.0 × fontSize; cursorGap 4-12 px; cursorBaselineFix small negative number to drop below the baseline
|
||||
- Effects: too-thin cursor disappears in render compression; too-tall cursor visually outranks the text
|
||||
- Constraints: must use `display: inline-block` (a `width` on `display: inline` is ignored)
|
||||
- Reference: see the corresponding blueprint's example HTML
|
||||
|
||||
- **cursorHeightEmphasis** (Variations) — height when the active segment is the brand
|
||||
- Range: 1.1-1.25 × `cursorHeight`
|
||||
- Effects: subtle bump reads as emphasis; large bump reads as glitch
|
||||
- Constraints: `cursorHeightEmphasis > cursorHeight`
|
||||
- Reference: see the corresponding blueprint's example HTML
|
||||
|
||||
- **BLINK_CYCLES_PER_SCENE** — how many full blink cycles span `DURATION`
|
||||
- Range: choose so the period `DURATION / BLINK_CYCLES_PER_SCENE` ≈ 0.6-1.2 s; e.g. an 8-second scene with ~1 s period uses BLINK_CYCLES_PER_SCENE = 8
|
||||
- Effects: short period (many cycles) reads as glitchy / agitated; long period reads as terminal-idle
|
||||
- Constraints: must be a whole number when `DURATION` is fixed — the sin sweep ends mid-cycle otherwise and the cursor pops on the last frame
|
||||
- Reference: see the corresponding blueprint's example HTML
|
||||
|
||||
- **TYPING_GRACE** (Variations) — seconds after a text change during which blink is suppressed
|
||||
- Range: 0.15-0.3 s
|
||||
- Effects: low end still blinks while letters are still appearing; high end keeps cursor solid through long holds
|
||||
- Constraints: must be smaller than the shortest dwell between two adjacent SEQUENCE entries — otherwise the cursor never blinks
|
||||
- Reference: see the corresponding blueprint's example HTML
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Timeline must be paused**: `gsap.timeline({ paused: true })`
|
||||
- **Registry key = `data-composition-id`**
|
||||
- **No CSS animation** on cursor — must be timeline-driven (blink + color)
|
||||
- **Cursor `display: inline-block`** — required for width/height
|
||||
- **`white-space: pre`** on text container and text — preserve trailing space
|
||||
- **Monospace font** — proportional fonts cause cursor to drift mid-segment
|
||||
|
||||
## Combinations
|
||||
|
||||
- [discrete-text-sequence.md](discrete-text-sequence.md) — uses the same SEQUENCE array pattern; this rule adds the cursor styling layer
|
||||
- [camera-cursor-tracking.md](camera-cursor-tracking.md) — camera tracks the cursor across the typing
|
||||
- [press-release-spring.md](press-release-spring.md) — after typing completes, a button press confirms the command
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — onUpdate driving cursor color + sin blink
|
||||
- `/hyperframes-core` — composition wiring
|
||||
- `/hyperframes-cli` — `hyperframes lint`
|
||||
@@ -0,0 +1,332 @@
|
||||
---
|
||||
name: coordinate-target-zoom
|
||||
description: Zoom into a specific non-centered element by combining scale with counter-translation — target ends at viewport center after the zoom completes.
|
||||
metadata:
|
||||
tags: camera, zoom, scale, translate, target, off-center, focus
|
||||
---
|
||||
|
||||
# Coordinate Target Zoom
|
||||
|
||||
A simple `scale > 1` on a wrapper pushes off-center content OFF the visible canvas. To zoom _into_ a specific non-centered element, apply scale AND an inverse translation in lockstep so the target lands at viewport center.
|
||||
|
||||
## How It Works
|
||||
|
||||
Two nested wrappers, separated concerns:
|
||||
|
||||
1. **Outer wrapper** applies `scale` (the zoom)
|
||||
2. **Inner wrapper** applies `translate(x, y)` (the counter-shift)
|
||||
|
||||
The translate is the **negation** of the target's offset from center. The inner translate moves the target back to the outer's transform-origin BEFORE the outer scale fires, so the scale around center maps the target to 0.
|
||||
|
||||
```
|
||||
T = -offset
|
||||
```
|
||||
|
||||
Derivation (outer scales the inner-translated content):
|
||||
|
||||
1. Inner translate moves target by T in pre-scale units → target at `offset + T`
|
||||
2. Outer scale S (around center 0,0) maps that to `S × (offset + T)`
|
||||
3. For target to land at viewport center: `S × (offset + T) = 0` → **`T = -offset`**
|
||||
|
||||
Note: the formula does NOT depend on S. The translate amount is the same whether you zoom 1.5×, 2×, or 3× — as long as the OUTER is the scale and the INNER is the translate, and scale uses `transform-origin: 50% 50%`.
|
||||
|
||||
## Getting the offset
|
||||
|
||||
`T = -offset` is only as good as `offset`. The #1 way this pattern ships broken is hand-computing `offset` from a layout formula, getting the **sign** or magnitude wrong, and letting the zoom amplify a small error off-screen. **Default to measuring the target's real laid-out center; reserve the formula for symmetric rows.**
|
||||
|
||||
### Default — measure the target's actual center (works for ANY layout)
|
||||
|
||||
Read where the target actually is, once, at setup. This is immune to sign errors because it's derived from the rendered DOM, not a mental model:
|
||||
|
||||
```js
|
||||
await document.fonts.ready; // metrics final; fallback fonts are 10–30px off → tens of px after a 3×+ zoom
|
||||
const W = 1920,
|
||||
H = 1080;
|
||||
const r = document.getElementById("target-card").getBoundingClientRect();
|
||||
const TARGET_OFFSET_X = r.left + r.width / 2 - W / 2;
|
||||
const TARGET_OFFSET_Y = r.top + r.height / 2 - H / 2;
|
||||
// bake these; feed counterX/Y = -TARGET_OFFSET_X/Y to the inner tween
|
||||
```
|
||||
|
||||
This `getBoundingClientRect` runs **once at setup**, before timeline registration — NOT per-frame (per-frame DOM reads desync under the renderer's parallel sampling; see SKILL universal constraints). Because the measurement is async (`fonts.ready`), build and register the timeline inside the same `async` setup so the baked offset is ready before `window.__timelines[id]` is published.
|
||||
|
||||
### Shortcut — symmetric equal-width row ONLY
|
||||
|
||||
If (and only if) the target is one of N **equal-width** cards in a centered row with uniform gaps, you may skip measurement:
|
||||
|
||||
```js
|
||||
const index_offset = targetIndex - (N - 1) / 2;
|
||||
const TARGET_OFFSET_X = index_offset * (CARD_WIDTH + CARD_GAP);
|
||||
```
|
||||
|
||||
⚠️ This assumes every sibling is the **same width**. The moment the row is asymmetric — a wide companion label beside a narrow chip, a wordmark flanked by unequal elements — it gives the wrong answer, often the wrong **sign**: the heavier side shifts the centered target the _opposite_ way you'd guess. (A real example: `companion(220) + gap + wordmark + gap + chip(110)` puts the wordmark ~55px **right** of center, but the "chip − companion" intuition says left.) For anything but equal cards, **measure**.
|
||||
|
||||
### Headroom budget — cap the scale from the measured size
|
||||
|
||||
A zoom multiplies any centering error, so leave margin. Keep the target ≤ ~88% of the canvas at peak; derive the cap from the measured size instead of picking a round number by feel:
|
||||
|
||||
```js
|
||||
const maxScale = Math.min((0.88 * W) / r.width, (0.88 * H) / r.height);
|
||||
const ZOOM_SCALE = Math.min(DESIRED_SCALE, maxScale);
|
||||
```
|
||||
|
||||
A target that fills 97%+ of the frame reads as cut-off the instant its center is even slightly off — and a hand-baked offset always is. (The perception gate flags this as `primary-offscreen`, and `data-layout-allow-overflow` does **not** exempt it.)
|
||||
|
||||
## HTML
|
||||
|
||||
```html
|
||||
<div
|
||||
class="scene"
|
||||
id="zoom-scene"
|
||||
data-composition-id="zoom-scene"
|
||||
data-start="0"
|
||||
data-duration="5"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div class="zoom-outer" id="zoom-outer">
|
||||
<div class="zoom-inner" id="zoom-inner">
|
||||
<div class="content">
|
||||
<!-- Several layout elements; one is the "target" -->
|
||||
<div class="card other">
|
||||
<div class="label">{label1}</div>
|
||||
<div class="price">{price1}</div>
|
||||
</div>
|
||||
<div class="card other">
|
||||
<div class="label">{label2}</div>
|
||||
<div class="price">{price2}</div>
|
||||
</div>
|
||||
<div class="card target" id="target-card">
|
||||
<div class="label">{targetLabel}</div>
|
||||
<div class="price">{targetPrice}</div>
|
||||
<div class="tag">{targetTagline}</div>
|
||||
</div>
|
||||
<div class="card other">
|
||||
<div class="label">{label4}</div>
|
||||
<div class="price">{price4}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS
|
||||
|
||||
```css
|
||||
.scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden; /* REQUIRED — see Critical Constraints */
|
||||
background: {bgGradient};
|
||||
}
|
||||
.zoom-outer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transform-origin: 50% 50%;
|
||||
will-change: transform;
|
||||
}
|
||||
.zoom-inner {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
will-change: transform;
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
gap: CARD_GAP;
|
||||
}
|
||||
.card {
|
||||
width: CARD_WIDTH;
|
||||
padding: CARD_PADDING;
|
||||
border-radius: CARD_RADIUS;
|
||||
background: {cardBg};
|
||||
border: 1px solid {cardBorder};
|
||||
text-align: center;
|
||||
font-family: {font};
|
||||
}
|
||||
.card.target {
|
||||
background: {targetCardBg}; /* slightly brighter than .card */
|
||||
border: 2px solid {targetBorder};
|
||||
box-shadow: {targetGlow};
|
||||
}
|
||||
.label {
|
||||
font-size: LABEL_FONT_SIZE;
|
||||
font-weight: 800;
|
||||
letter-spacing: 6px;
|
||||
text-transform: uppercase;
|
||||
color: {labelColor};
|
||||
}
|
||||
.price {
|
||||
font-size: PRICE_FONT_SIZE;
|
||||
font-weight: 900;
|
||||
color: {textColor};
|
||||
margin: 16px 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.tag {
|
||||
font-size: TAG_FONT_SIZE;
|
||||
font-weight: 700;
|
||||
letter-spacing: 4px;
|
||||
color: {accentColor};
|
||||
opacity: 0;
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// TARGET_OFFSET_X / TARGET_OFFSET_Y and ZOOM_SCALE come from the "Getting the
|
||||
// offset" section above — MEASURED at setup (after fonts.ready) and baked. Do NOT
|
||||
// hand-derive the offset for a non-symmetric layout (wrong sign → the zoom shoves
|
||||
// the target off-frame). For a measured target, build the timeline inside that
|
||||
// async setup so the offset is ready before window.__timelines[id] is published.
|
||||
|
||||
// Counter-translation = -offset (inner translate cancels target offset BEFORE outer scales)
|
||||
const counterX = -TARGET_OFFSET_X;
|
||||
const counterY = -TARGET_OFFSET_Y;
|
||||
|
||||
// Phase 1 — cards reveal
|
||||
tl.from(
|
||||
".card",
|
||||
{ opacity: 0, y: REVEAL_Y, stagger: REVEAL_STAGGER, duration: REVEAL_DUR, ease: "power3.out" },
|
||||
REVEAL_START,
|
||||
);
|
||||
|
||||
// Phase 2 — pause to let viewer scan the layout
|
||||
|
||||
// Phase 3 — zoom into target
|
||||
tl.to(
|
||||
"#zoom-outer",
|
||||
{
|
||||
scale: ZOOM_SCALE,
|
||||
duration: ZOOM_DUR,
|
||||
ease: "power3.inOut",
|
||||
},
|
||||
ZOOM_START,
|
||||
);
|
||||
tl.to(
|
||||
"#zoom-inner",
|
||||
{
|
||||
x: counterX,
|
||||
y: counterY,
|
||||
duration: ZOOM_DUR,
|
||||
ease: "power3.inOut",
|
||||
},
|
||||
ZOOM_START,
|
||||
);
|
||||
|
||||
// Phase 4 — target "tag" reveals inside the zoomed-in target
|
||||
tl.to(
|
||||
".target .tag",
|
||||
{ opacity: 1, duration: TAG_REVEAL_DUR, ease: "power2.out" },
|
||||
TAG_REVEAL_START,
|
||||
);
|
||||
|
||||
// Phase 5 — climax dwell — viewer reads the target content
|
||||
// (no additional motion; the zoomed-in state holds for DWELL_DUR seconds)
|
||||
|
||||
window.__timelines["zoom-scene"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
### Dynamic target lookup via `getBoundingClientRect`
|
||||
|
||||
This is now the **default**, not a variation — see [Getting the offset](#getting-the-offset). Always `await document.fonts.ready` before measuring (fallback-font metrics are off by 10–30px, which a 3×+ zoom magnifies into tens of visible px) and measure **once at setup**, never per-frame.
|
||||
|
||||
### Zoom out (target → wide view)
|
||||
|
||||
Reverse the phases — start at zoomed-in, then `scale: 1` + `x: 0, y: 0` to pull back. The "reveal" beat is the panorama.
|
||||
|
||||
### Multi-target zoom sequence
|
||||
|
||||
Chain multiple zooms: target A (1.5-2.5s) → pause → target B (3-4s) → pull back (4.5-5s). Each segment needs its own counter-translation pair.
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
### Layout
|
||||
|
||||
- **CARD_WIDTH / CARD_GAP / CARD_PADDING / CARD_RADIUS** — geometric layout.
|
||||
- Constraints: `N × CARD_WIDTH + (N-1) × CARD_GAP < viewportWidth` so all cards fit pre-zoom
|
||||
- Effects: smaller cards → more siblings on screen → busier composition; larger cards → fewer siblings, more emphasis per card
|
||||
- **LABEL_FONT_SIZE / PRICE_FONT_SIZE / TAG_FONT_SIZE** — typographic hierarchy.
|
||||
- Range: tag < label < price (price is the focal element after zoom; sizing it largest reinforces this)
|
||||
|
||||
### Reveal phase
|
||||
|
||||
- **REVEAL_START** — when the cards begin fading in.
|
||||
- Constraints: typically a small offset (~0.2s) for a beat of black before content appears
|
||||
- **REVEAL_DUR** — per-card fade-up duration.
|
||||
- Range: 0.4-0.8s
|
||||
- **REVEAL_Y** — initial vertical offset of each card before fade-up (in px).
|
||||
- Range: 16-48 px; bigger feels "thrown in," smaller feels gentle
|
||||
- **REVEAL_STAGGER** — delay between consecutive card reveals.
|
||||
- Range: 0.06-0.15s; calibrated so all cards finish before `ZOOM_START`
|
||||
|
||||
### Zoom phase
|
||||
|
||||
- **ZOOM_START** — when the zoom begins.
|
||||
- Constraints: `≥ REVEAL_START + REVEAL_DUR + (N-1) × REVEAL_STAGGER + viewer-scan-time` (give viewer 0.5-1.5s to read the layout before zooming)
|
||||
- **ZOOM_DUR** — duration of the zoom tween.
|
||||
- Range: 1.0-2.0s; under 0.8s feels like a teleport, over 2.5s drags
|
||||
- Constraints: scale tween + counter-translate tween MUST share this duration AND ease
|
||||
- **ZOOM_SCALE** — final magnification.
|
||||
- Range: 1.5× (modest emphasis) → 3× (dominant focus) → 5×+ (cinematic extreme)
|
||||
- Constraints: card content must remain crisp at this scale; raster source media needs `sourceResolution ≥ rendered × ZOOM_SCALE`
|
||||
- **Headroom budget**: cap from the measured target size so the target stays ≤ ~88% of the canvas at peak — `ZOOM_SCALE = Math.min(DESIRED, 0.88×W/r.width, 0.88×H/r.height)`. Picking a round number by feel (e.g. 3.2× on a 585px wordmark → 1872px = 97% of 1920) leaves no margin, so any centering slop cuts the text off.
|
||||
|
||||
### Target reveal + dwell
|
||||
|
||||
- **TAG_REVEAL_START** — when the target's hidden tag fades in.
|
||||
- Constraints: `≥ ZOOM_START + ZOOM_DUR` (only reveal after the zoom settles, so viewer's eye is already on the target)
|
||||
- **TAG_REVEAL_DUR** — tag fade-in duration.
|
||||
- Range: 0.3-0.6s
|
||||
- **DWELL_DUR** — post-zoom hold so the viewer reads the target.
|
||||
- Range: ≥ 1.0s after tag reveals (see "Climax dwell" in Key Principles)
|
||||
|
||||
### Color tokens
|
||||
|
||||
- **{bgGradient}** — typically a dark radial gradient to vignette the cards
|
||||
- **{cardBg} / {cardBorder}** — non-target cards (subtle, recessive)
|
||||
- **{targetCardBg} / {targetBorder} / {targetGlow}** — target card visually brighter / haloed so the eye lands there before the zoom even fires
|
||||
- **{labelColor} / {textColor} / {accentColor}** — hierarchical text colors; `{accentColor}` reserved for the tag (pops on reveal)
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Measure the offset, don't hand-derive it** — for any layout that isn't a symmetric equal-width row, read the target's real center with `getBoundingClientRect` at setup (after `fonts.ready`) and bake it (see [Getting the offset](#getting-the-offset)). Hand-computed offsets silently get the **sign** wrong on asymmetric layouts, and the zoom amplifies the error off-screen — the single most common way this pattern ships broken.
|
||||
- **Transform order — outer scales, inner translates** — DO NOT put scale and translate on the SAME element. The transform math becomes tangled (`translate * scale` ≠ `scale * translate` in CSS transform composition). Nested wrappers cleanly separate concerns.
|
||||
- **Counter-translate = -offset** — independent of scale. Derive from: outer scale around center maps `(offset + T)` to `S × (offset + T)`. Setting that to zero gives `T = -offset`. A common wrong intuition is `T = -offset × (S - 1)` — it happens to give the same answer at S=2 but is wrong for any other S.
|
||||
- **`transform-origin: 50% 50%` on outer wrapper** — non-center origin causes unpredictable inner offset; always center.
|
||||
- **`overflow: hidden` on `.scene` REQUIRED** — at zoom > 1, the outer-scaled content can leak beyond the 1920×1080 frame.
|
||||
- **Tween scale and counter-translate together** — they MUST share `duration` and `ease`. Otherwise the target drifts mid-zoom (visible "wandering"). Easiest: pass identical params to both tweens at the same time position.
|
||||
- **❗ Climax dwell ≥1s after zoom completes** — see SKILL universal constraints. If zoom ends at t=3.0 in a 3.5s comp, viewer barely sees the target; aim for 1.5-2s post-zoom dwell.
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Timeline must be paused**: `gsap.timeline({ paused: true })`
|
||||
- **Registry key = `data-composition-id`**
|
||||
- **No CSS `transition` on `.zoom-outer` or `.zoom-inner`** — competes with GSAP
|
||||
- **`will-change: transform`** on both wrappers — the transforms update every frame during the zoom phase
|
||||
- **`transform-origin: 50% 50%` on `.zoom-outer`** — center-based scaling is what the counter-translate math assumes
|
||||
- **Target offset baked once, at setup, from measurement** — measure the target center after `fonts.ready` and bake (see [Getting the offset](#getting-the-offset)); never recompute per-frame in onUpdate, and never hand-estimate the offset for a non-symmetric layout
|
||||
- **Scale within the headroom budget** — keep the target ≤ ~88% of the canvas at peak, derived from the measured size (`maxScale = 0.88 × W / measuredWidth`); a target that fills the frame is cut off the instant the center is slightly off
|
||||
|
||||
## Combinations
|
||||
|
||||
- [multi-phase-camera.md](multi-phase-camera.md) — multi-phase camera that includes a coordinate-target-zoom phase
|
||||
- [sine-wave-loop.md](sine-wave-loop.md) — idle breathing on the target AFTER zoom settles
|
||||
- [discrete-text-sequence.md](discrete-text-sequence.md) — text assembly in the target BEFORE zoom completes
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — two coordinated tweens
|
||||
- `/hyperframes-core` — composition wiring
|
||||
- `/hyperframes-cli` — `hyperframes lint`
|
||||
@@ -0,0 +1,283 @@
|
||||
---
|
||||
name: counting-dynamic-scale
|
||||
description: Counter animation where font size grows with the counting value, creating escalating visual weight.
|
||||
metadata:
|
||||
tags: counter, counting, scale, font-size, number, dynamic, emphasis
|
||||
---
|
||||
|
||||
# Counting with Dynamic Scale
|
||||
|
||||
A number counts from A → B while its font size simultaneously grows, creating escalating visual weight that reinforces magnitude.
|
||||
|
||||
## How It Works
|
||||
|
||||
A single eased timeline drives **two synchronized properties**:
|
||||
|
||||
1. The numeric value (rendered as DOM text via `onUpdate`)
|
||||
2. The font size (tweened from `START_SIZE` → `END_SIZE`)
|
||||
|
||||
As the number gets bigger, the text gets larger — visually communicating "this is impressive."
|
||||
|
||||
## Easing
|
||||
|
||||
Pick by drama desired (the choice is discrete; coefficient is implicit):
|
||||
|
||||
| GSAP ease | Effect |
|
||||
| ------------ | --------------------------------------------- |
|
||||
| `power1.out` | Mild — slight deceleration |
|
||||
| `power2.out` | Default — ease-out, fast start slow end |
|
||||
| `power3.out` | Strong — dramatic deceleration ⭐ recommended |
|
||||
| `expo.out` | Very dramatic — almost stops at the end |
|
||||
|
||||
`power3.out` matches the polynomial `1 - (1-x)^k` family at k ≈ 2.5 — number rushes up then slows dramatically at the peak.
|
||||
|
||||
## HTML
|
||||
|
||||
```html
|
||||
<div
|
||||
class="scene"
|
||||
data-composition-id="counter-scene"
|
||||
data-start="0"
|
||||
data-duration="3"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div class="counter-wrap">
|
||||
<span class="counter" id="counter">0</span><span class="counter-suffix">{suffix}</span>
|
||||
</div>
|
||||
<div class="counter-label">{label}</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS
|
||||
|
||||
```css
|
||||
.scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: {bgColor};
|
||||
}
|
||||
|
||||
.counter-wrap {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
/* Fixed-width container prevents layout shift as digit count changes */
|
||||
width: {counterContainerWidth};
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.counter {
|
||||
font-family: {font};
|
||||
font-weight: 900;
|
||||
color: {textColor};
|
||||
/* MANDATORY — tabular-nums keeps digits the same width */
|
||||
font-variant-numeric: tabular-nums;
|
||||
/* Initial font-size; GSAP will tween this */
|
||||
font-size: {startSize};
|
||||
letter-spacing: -2px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.counter-suffix {
|
||||
font-family: {font};
|
||||
font-weight: 800;
|
||||
color: {accentColor};
|
||||
font-size: {suffixSize};
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
.counter-label {
|
||||
margin-top: 24px;
|
||||
font-family: {font};
|
||||
font-size: {labelSize};
|
||||
color: {mutedTextColor};
|
||||
text-align: center;
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
const counter = document.getElementById("counter");
|
||||
const state = { value: 0, fontSize: START_SIZE };
|
||||
|
||||
// Synchronized count + font-size tween
|
||||
tl.to(
|
||||
state,
|
||||
{
|
||||
value: TARGET_VALUE,
|
||||
fontSize: END_SIZE,
|
||||
duration: COUNT_DUR,
|
||||
ease: COUNT_EASE,
|
||||
onUpdate: () => {
|
||||
counter.textContent = Math.round(state.value).toLocaleString();
|
||||
counter.style.fontSize = `${state.fontSize}px`;
|
||||
},
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
// Suffix slides in after count completes
|
||||
tl.to(
|
||||
".counter-suffix",
|
||||
{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
duration: SUFFIX_DUR,
|
||||
ease: `back.out(${SUFFIX_BOUNCE_FACTOR})`,
|
||||
},
|
||||
COUNT_DUR,
|
||||
);
|
||||
|
||||
// Label fades in early
|
||||
tl.from(
|
||||
".counter-label",
|
||||
{
|
||||
opacity: 0,
|
||||
y: 12,
|
||||
duration: LABEL_DUR,
|
||||
ease: "power2.out",
|
||||
},
|
||||
LABEL_AT,
|
||||
);
|
||||
|
||||
window.__timelines["counter-scene"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
- **TARGET_VALUE** — the number the counter lands on
|
||||
- Effects: 2–3 digits reads best at hero size; 4+ digits requires wider container
|
||||
- Constraints: must fit horizontally at END_SIZE inside the container
|
||||
|
||||
- **START_SIZE / END_SIZE** — initial and final font size
|
||||
- Range: START_SIZE ≈ 40–60 % of END_SIZE
|
||||
- Effects: smaller START_SIZE = more dramatic growth; larger = subtler
|
||||
- Constraints: END_SIZE × digit count must fit the container width without clipping
|
||||
|
||||
- **COUNT_DUR** — count + scale tween duration
|
||||
- Range: 1.2–2.5 s
|
||||
- Effects: shorter = aggressive; longer = settled, gives reading time
|
||||
- Constraints: must allow the eye to read the digits scrolling past; below ~0.8 s reads as a flash
|
||||
|
||||
- **COUNT_EASE** — shared ease for value AND font-size
|
||||
- Discrete choice: `power2.out`, `power3.out`, `expo.out` (see table above)
|
||||
- Constraint: avoid `back.out` / `elastic.out` — overshoot reads as unstable data
|
||||
|
||||
- **SUFFIX_DUR** — duration of the suffix slide-in
|
||||
- Range: 0.3–0.6 s
|
||||
- Effects: shorter = snap; longer = floats
|
||||
- Constraints: must fire after the count lands (started at COUNT_DUR), not during
|
||||
|
||||
- **SUFFIX_BOUNCE_FACTOR** — back.out coefficient on the suffix entry
|
||||
- Range: 1.4–2.0
|
||||
- Effects: 1.4 = small overshoot; 2.0 = bouncy
|
||||
|
||||
- **LABEL_AT / LABEL_DUR** — when and how long the label fades in
|
||||
- Range: LABEL_AT < COUNT_DUR / 2 (label arrives before count peaks); LABEL_DUR 0.4–0.7 s
|
||||
|
||||
## Variations
|
||||
|
||||
### Direct `innerText` tween (no proxy object)
|
||||
|
||||
The GSAP inspector reads `innerText` directly, so a number-only counter can skip the `state` proxy:
|
||||
|
||||
```js
|
||||
tl.to(
|
||||
counter,
|
||||
{ innerText: TARGET_VALUE, duration: COUNT_DUR, ease: COUNT_EASE, snap: { innerText: 1 } },
|
||||
0,
|
||||
);
|
||||
```
|
||||
|
||||
`snap: { innerText: 1 }` keeps it integer. Keep the proxy-object `onUpdate` form (above) whenever you must **co-drive** font-size, locale formatting (`toLocaleString`), or a suffix in the same tween — `innerText` alone can't do those, and dynamic scale is the whole point of this rule, so the proxy form is the default here.
|
||||
|
||||
### 3D depth entry
|
||||
|
||||
Combine with `translateZ` for parallax-style depth on entry:
|
||||
|
||||
```js
|
||||
tl.from(
|
||||
".counter",
|
||||
{
|
||||
z: -300,
|
||||
duration: 0.6,
|
||||
ease: "power2.out",
|
||||
// requires parent or .counter itself to have perspective set
|
||||
},
|
||||
0,
|
||||
);
|
||||
```
|
||||
|
||||
CSS prerequisite:
|
||||
|
||||
```css
|
||||
.counter-wrap {
|
||||
perspective: 1000px;
|
||||
}
|
||||
.counter {
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
```
|
||||
|
||||
### Multi-stat coordinated reveal
|
||||
|
||||
For 3 stats counting in parallel, share the SAME ease and duration so they finish together — visually a chord, not arpeggio. Each stat usually also needs a **paired graphic** (bar / ring / stars) — don't stop at the number; see [stat-bars-and-fills.md](stat-bars-and-fills.md):
|
||||
|
||||
```js
|
||||
["#stat1", "#stat2", "#stat3"].forEach((sel, i) => {
|
||||
const obj = { v: 0 };
|
||||
tl.to(
|
||||
obj,
|
||||
{
|
||||
v: TARGETS[i],
|
||||
duration: COUNT_DUR,
|
||||
ease: COUNT_EASE,
|
||||
onUpdate: () => (document.querySelector(sel).textContent = Math.round(obj.v)),
|
||||
},
|
||||
0,
|
||||
); // same start position — chord
|
||||
});
|
||||
```
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Synchronized value + size in ONE tween** so they share an ease and stay coordinated
|
||||
- **`font-variant-numeric: tabular-nums` is mandatory** — without it digit-count transitions (e.g. 9 → 10 → 100) cause visible jitter as glyph widths change
|
||||
- **Fixed-width container** as belt-and-suspenders — even with tabular-nums, glyph shape changes can shift baselines
|
||||
- **Grow in place, don't bounce** — the number should feel weighty, not springy. `power3.out` ends at exact value; `back.out` overshoots and feels cartoonish
|
||||
- **Start small enough to grow noticeably** (~50 % of final size); end large enough to feel decisive but not clip viewport
|
||||
- **Suffix animates AFTER the count, not during** — gives the number its own beat
|
||||
- **❗ Label is BIG TEXT, not a page-style tiny caption** — for VIDEO, a small paragraph-style caption below a hero-size number reads as visual noise. Use display-size, uppercase, tracked label so the layout is "two-line big-text"; the label is part of the headline, not a footer.
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **`tabular-nums` mandatory** — required CSS for layout stability
|
||||
- **Timeline must be paused**: `gsap.timeline({ paused: true })`. Never `tl.play()`
|
||||
- **Registry key = `data-composition-id`**: `window.__timelines["counter-scene"]` must match scene root
|
||||
- **`onUpdate` mutates DOM**: HF runtime seeks the timeline frame-by-frame, so `onUpdate` runs on every seek call. Keep `onUpdate` work O(1) — set text + font-size, no DOM creation
|
||||
- **`Math.round` not `Math.floor`** — half-way through the final integer should display the final value briefly, not the previous one
|
||||
- **Avoid `back.out` / `elastic.out`** for the counter itself — overshoot makes the number look unstable (it's data, not decoration)
|
||||
|
||||
## Combinations
|
||||
|
||||
- [stat-bars-and-fills.md](stat-bars-and-fills.md) — **the paired graphic beside the number** (growth bars / progress ring / star wipe). A stat scene is usually BOTH rules: the count-up here + a fill there. Give the fill the same ease and duration so number and graphic land as one beat.
|
||||
- [svg-path-draw.md](svg-path-draw.md) — icons drawing in around the number
|
||||
- [center-outward-expansion.md](center-outward-expansion.md) — related icons exploding outward synced to count peak
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — timeline + `onUpdate` API
|
||||
- `/hyperframes-core` — composition wiring, `data-*` attributes
|
||||
- `/hyperframes-cli` — `hyperframes lint` to verify scene
|
||||
@@ -0,0 +1,373 @@
|
||||
# CSS Patterns for Marker Highlighting
|
||||
|
||||
Pure CSS + GSAP implementations of all five MarkerHighlight.js drawing modes. Use these for deterministic rendering in HyperFrames compositions — no external library dependency, full GSAP timeline control.
|
||||
|
||||
## Contents
|
||||
|
||||
- [1. Highlight Mode](#1-highlight-mode) — Yellow marker sweep behind text
|
||||
- [2. Circle Mode](#2-circle-mode) — Hand-drawn ellipse around text
|
||||
- [3. Burst Mode](#3-burst-mode) — Radiating lines from text
|
||||
- [4. Scribble Mode](#4-scribble-mode) — Chaotic scribble over text
|
||||
- [5. Sketchout Mode](#5-sketchout-mode) — Rough rectangle outline
|
||||
|
||||
## 1. Highlight Mode
|
||||
|
||||
Yellow marker sweep behind text. The most common mode.
|
||||
|
||||
```html
|
||||
<span class="mh-highlight-wrap">
|
||||
<span class="mh-highlight-bar" id="hl-1"></span>
|
||||
<span class="mh-highlight-text">highlighted text</span>
|
||||
</span>
|
||||
```
|
||||
|
||||
```css
|
||||
.mh-highlight-wrap {
|
||||
position: relative;
|
||||
display: inline;
|
||||
}
|
||||
.mh-highlight-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -6px;
|
||||
right: -6px;
|
||||
bottom: 0;
|
||||
background: #fdd835;
|
||||
opacity: 0.35;
|
||||
transform: scaleX(0);
|
||||
transform-origin: left center;
|
||||
border-radius: 3px;
|
||||
z-index: 0;
|
||||
}
|
||||
.mh-highlight-text {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// Sweep in from left
|
||||
tl.to("#hl-1", { scaleX: 1, duration: 0.5, ease: "power2.out" }, 0.6);
|
||||
|
||||
// Optional: skew for hand-drawn feel
|
||||
// gsap.set("#hl-1", { skewX: -2 });
|
||||
```
|
||||
|
||||
### Multi-line Highlight
|
||||
|
||||
Stagger bars across multiple lines:
|
||||
|
||||
```js
|
||||
tl.to(
|
||||
".mh-highlight-bar",
|
||||
{
|
||||
scaleX: 1,
|
||||
duration: 0.5,
|
||||
ease: "power2.out",
|
||||
stagger: 0.3,
|
||||
},
|
||||
0.6,
|
||||
);
|
||||
```
|
||||
|
||||
## 2. Circle Mode
|
||||
|
||||
Hand-drawn circle around text. Use `border-radius: 50%` with a slight rotation for organic feel.
|
||||
|
||||
```html
|
||||
<span class="mh-circle-wrap">
|
||||
<span class="mh-circle-text" id="circle-word">IMPORTANT</span>
|
||||
<span class="mh-circle-ring" id="circle-1"></span>
|
||||
</span>
|
||||
```
|
||||
|
||||
```css
|
||||
.mh-circle-wrap {
|
||||
position: relative;
|
||||
display: inline;
|
||||
}
|
||||
.mh-circle-text {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.mh-circle-ring {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 130%;
|
||||
height: 160%;
|
||||
transform: translate(-50%, -50%) rotate(-3deg) scale(0);
|
||||
border: 3px solid #e53935;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// Circle scales in with a wobble
|
||||
tl.to(
|
||||
"#circle-1",
|
||||
{
|
||||
scale: 1,
|
||||
rotation: -3,
|
||||
duration: 0.6,
|
||||
ease: "back.out(1.7)",
|
||||
transformOrigin: "center center",
|
||||
},
|
||||
0.7,
|
||||
);
|
||||
```
|
||||
|
||||
### Variations
|
||||
|
||||
```css
|
||||
/* Tighter circle (for short words) */
|
||||
.mh-circle-ring.tight {
|
||||
width: 150%;
|
||||
height: 180%;
|
||||
}
|
||||
|
||||
/* Squared circle (rounded rectangle) */
|
||||
.mh-circle-ring.rounded {
|
||||
border-radius: 30%;
|
||||
width: 120%;
|
||||
height: 140%;
|
||||
}
|
||||
|
||||
/* Ellipse (wider than tall) */
|
||||
.mh-circle-ring.ellipse {
|
||||
width: 150%;
|
||||
height: 130%;
|
||||
border-radius: 50%;
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Burst Mode
|
||||
|
||||
Radiating lines from text center. Each line is a positioned div rotated to its angle.
|
||||
|
||||
```html
|
||||
<span class="mh-burst-wrap">
|
||||
<span class="mh-burst-text">WOW</span>
|
||||
<span class="mh-burst-container" id="burst-1">
|
||||
<span class="mh-burst-line" style="--angle: 0deg; --len: 70px;"></span>
|
||||
<span class="mh-burst-line" style="--angle: 30deg; --len: 55px;"></span>
|
||||
<span class="mh-burst-line" style="--angle: 60deg; --len: 80px;"></span>
|
||||
<span class="mh-burst-line" style="--angle: 90deg; --len: 45px;"></span>
|
||||
<span class="mh-burst-line" style="--angle: 120deg; --len: 65px;"></span>
|
||||
<span class="mh-burst-line" style="--angle: 150deg; --len: 75px;"></span>
|
||||
<span class="mh-burst-line" style="--angle: 180deg; --len: 50px;"></span>
|
||||
<span class="mh-burst-line" style="--angle: 210deg; --len: 60px;"></span>
|
||||
<span class="mh-burst-line" style="--angle: 240deg; --len: 80px;"></span>
|
||||
<span class="mh-burst-line" style="--angle: 270deg; --len: 40px;"></span>
|
||||
<span class="mh-burst-line" style="--angle: 300deg; --len: 70px;"></span>
|
||||
<span class="mh-burst-line" style="--angle: 330deg; --len: 55px;"></span>
|
||||
</span>
|
||||
</span>
|
||||
```
|
||||
|
||||
```css
|
||||
.mh-burst-wrap {
|
||||
position: relative;
|
||||
display: inline;
|
||||
}
|
||||
.mh-burst-text {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.mh-burst-container {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
.mh-burst-line {
|
||||
position: absolute;
|
||||
display: block;
|
||||
width: 3px;
|
||||
height: var(--len);
|
||||
background: #1e88e5;
|
||||
left: -1.5px;
|
||||
top: calc(-1 * var(--len));
|
||||
transform: rotate(var(--angle));
|
||||
transform-origin: bottom center;
|
||||
opacity: 0;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// All lines burst outward simultaneously with slight stagger
|
||||
tl.fromTo(
|
||||
"#burst-1 .mh-burst-line",
|
||||
{ scaleY: 0, opacity: 0 },
|
||||
{ scaleY: 1, opacity: 1, duration: 0.4, ease: "power2.out", stagger: 0.03 },
|
||||
0.7,
|
||||
);
|
||||
```
|
||||
|
||||
**Vary line lengths** (40-80px range) for an organic, hand-drawn feel. Equal lengths look mechanical.
|
||||
|
||||
## 4. Scribble Mode
|
||||
|
||||
Wavy SVG underlines and strikethroughs that draw themselves via `stroke-dashoffset`.
|
||||
|
||||
```html
|
||||
<span class="mh-scribble-wrap">
|
||||
<span class="mh-scribble-text">underlined text</span>
|
||||
<svg class="mh-scribble-svg" viewBox="0 0 500 24" preserveAspectRatio="none">
|
||||
<path
|
||||
id="scribble-1"
|
||||
d="M0,12 Q31,0 62,12 Q93,24 125,12 Q156,0 187,12 Q218,24 250,12 Q281,0 312,12 Q343,24 375,12 Q406,0 437,12 Q468,24 500,12"
|
||||
fill="none"
|
||||
stroke="#FDD835"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
```
|
||||
|
||||
```css
|
||||
.mh-scribble-wrap {
|
||||
position: relative;
|
||||
display: inline;
|
||||
}
|
||||
.mh-scribble-text {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.mh-scribble-svg {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: -6px;
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
z-index: 0;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// Measure path length and set initial dash state
|
||||
var path = document.querySelector("#scribble-1");
|
||||
var len = path.getTotalLength();
|
||||
gsap.set(path, { strokeDasharray: len, strokeDashoffset: len });
|
||||
|
||||
// Draw the line
|
||||
tl.to(
|
||||
"#scribble-1",
|
||||
{
|
||||
strokeDashoffset: 0,
|
||||
duration: 0.8,
|
||||
ease: "power1.inOut",
|
||||
},
|
||||
0.7,
|
||||
);
|
||||
```
|
||||
|
||||
### Strikethrough Variant
|
||||
|
||||
Position the SVG at `top: 50%; transform: translateY(-50%)` instead of `bottom: -6px`.
|
||||
|
||||
### Wavy Path Generator
|
||||
|
||||
Scale the path's viewBox width to match text width. The wave pattern `Q x1,y1 x2,y2` alternates between `y=0` and `y=24` for a natural wobble. Adjust the control points for tighter or looser waves:
|
||||
|
||||
- **Tight waves**: smaller x-increments (25px per half-wave)
|
||||
- **Loose waves**: larger x-increments (50px per half-wave)
|
||||
- **Amplitude**: change the y range (0-24 for standard, 0-16 for subtle)
|
||||
|
||||
## 5. Sketchout Mode
|
||||
|
||||
Cross-hatch lines over de-emphasized text. Multiple angled lines create a "crossed out" effect.
|
||||
|
||||
```html
|
||||
<span class="mh-sketchout-wrap">
|
||||
<span class="mh-sketchout-text">old price</span>
|
||||
<span class="mh-sketchout-lines" id="sketchout-1">
|
||||
<span class="mh-sketchout-line mh-sketchout-fwd"></span>
|
||||
<span class="mh-sketchout-line mh-sketchout-bwd"></span>
|
||||
</span>
|
||||
</span>
|
||||
```
|
||||
|
||||
```css
|
||||
.mh-sketchout-wrap {
|
||||
position: relative;
|
||||
display: inline;
|
||||
}
|
||||
.mh-sketchout-text {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
.mh-sketchout-lines {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -4px;
|
||||
right: -4px;
|
||||
bottom: 0;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
}
|
||||
.mh-sketchout-line {
|
||||
position: absolute;
|
||||
display: block;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: #e53935;
|
||||
transform-origin: left center;
|
||||
transform: scaleX(0);
|
||||
}
|
||||
.mh-sketchout-fwd {
|
||||
transform: scaleX(0) rotate(-12deg);
|
||||
}
|
||||
.mh-sketchout-bwd {
|
||||
transform: scaleX(0) rotate(12deg);
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// Forward slash draws first
|
||||
tl.to(
|
||||
"#sketchout-1 .mh-sketchout-fwd",
|
||||
{
|
||||
scaleX: 1,
|
||||
duration: 0.3,
|
||||
ease: "power2.out",
|
||||
},
|
||||
1.0,
|
||||
);
|
||||
|
||||
// Backward slash follows
|
||||
tl.to(
|
||||
"#sketchout-1 .mh-sketchout-bwd",
|
||||
{
|
||||
scaleX: 1,
|
||||
duration: 0.3,
|
||||
ease: "power2.out",
|
||||
},
|
||||
1.15,
|
||||
);
|
||||
```
|
||||
|
||||
## Combining Modes in Captions
|
||||
|
||||
Use mode cycling for visual variety across caption groups:
|
||||
|
||||
```js
|
||||
var MODES = ["highlight", "circle", "burst", "scribble"];
|
||||
|
||||
GROUPS.forEach(function (group, gi) {
|
||||
var mode = MODES[gi % MODES.length];
|
||||
// Apply the mode's CSS pattern to emphasis words in this group
|
||||
group.emphasisWords.forEach(function (word) {
|
||||
applyMode(word.el, mode, tl, word.start);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Cycle every 2-3 groups for high energy, every 3-4 for medium, every 4-5 for low.
|
||||
@@ -0,0 +1,262 @@
|
||||
---
|
||||
name: cursor-click-ripple
|
||||
description: Animated mouse cursor moves to target, clicks with scale depression and expanding ripple rings.
|
||||
metadata:
|
||||
tags: cursor, click, ripple, interaction, mouse, button
|
||||
---
|
||||
|
||||
# Cursor Click Ripple
|
||||
|
||||
An animated cursor moves to a target element, performs a click with visual depression, and emits expanding ripple rings from the click point.
|
||||
|
||||
## How It Works
|
||||
|
||||
Three sequential phases driven by a single GSAP timeline:
|
||||
|
||||
1. **Move**: eased cursor translation from entry point to the target element's center
|
||||
2. **Click**: scale depression on both cursor and target (yoyo: shrink then return)
|
||||
3. **Ripple**: expanding circles radiate outward from the click point with fade-out. 1–3 staggered rings amplify the click feedback
|
||||
|
||||
Use a GSAP timeline because the phase ordering (move → settle → click → ripples) is exactly what timelines express cleanly.
|
||||
|
||||
## HTML
|
||||
|
||||
```html
|
||||
<div
|
||||
class="scene"
|
||||
id="cursor-click-scene"
|
||||
data-composition-id="cursor-click-scene"
|
||||
data-start="0"
|
||||
data-duration="2"
|
||||
data-track-index="0"
|
||||
>
|
||||
<button class="target-button">{ctaLabel}</button>
|
||||
|
||||
<div class="cursor">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M5 3L19 12L12 13L9 20L5 3Z"
|
||||
fill="{cursorFill}"
|
||||
stroke="{cursorStroke}"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Ripple rings — centered on click target, hidden until trigger -->
|
||||
<div class="ripple ripple-1"></div>
|
||||
<div class="ripple ripple-2"></div>
|
||||
<div class="ripple ripple-3"></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS
|
||||
|
||||
Position cursor at the entry point. Button sits at its final position. Ripples are at the click-target center with `scale: 0` and `opacity: 0` so they hold invisible until the timeline trigger:
|
||||
|
||||
```css
|
||||
.scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.target-button {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
/* ...button styling (background, color, font from project tokens) */
|
||||
}
|
||||
|
||||
.cursor {
|
||||
position: absolute;
|
||||
left: 10%;
|
||||
top: 80%; /* entry corner */
|
||||
pointer-events: none;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.ripple {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%; /* click target center */
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid {rippleColor};
|
||||
transform: translate(-50%, -50%) scale(0);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline
|
||||
|
||||
Build a paused timeline. Register it on `window.__timelines` with the same key as `data-composition-id` on the scene root. All tuning values are named constants — see How to Choose Values below.
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// MOVE_DUR, MOVE_EASE, CLICK_AT, PRESS_DUR, CURSOR_PRESS_SCALE, TARGET_PRESS_SCALE,
|
||||
// RIPPLE_AT, RIPPLE_DUR, RIPPLE_SCALE, RIPPLE_STAGGER, RIPPLE_EASE
|
||||
// — all named; values per How to Choose Values.
|
||||
|
||||
// Phase 1 — Move cursor to target center (eased, not linear)
|
||||
tl.to(
|
||||
".cursor",
|
||||
{
|
||||
x: TARGET_X,
|
||||
y: TARGET_Y,
|
||||
duration: MOVE_DUR,
|
||||
ease: MOVE_EASE,
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
// Phase 2 — Click: cursor + target depress together, then return
|
||||
tl.to(
|
||||
".cursor",
|
||||
{
|
||||
scale: CURSOR_PRESS_SCALE,
|
||||
duration: PRESS_DUR,
|
||||
ease: "power2.in",
|
||||
yoyo: true,
|
||||
repeat: 1,
|
||||
},
|
||||
CLICK_AT,
|
||||
);
|
||||
tl.to(
|
||||
".target-button",
|
||||
{
|
||||
scale: TARGET_PRESS_SCALE,
|
||||
duration: PRESS_DUR,
|
||||
ease: "power2.in",
|
||||
yoyo: true,
|
||||
repeat: 1,
|
||||
},
|
||||
CLICK_AT,
|
||||
);
|
||||
|
||||
// Phase 3 — Ripple burst, N rings staggered from the click point
|
||||
tl.set([".ripple-1", ".ripple-2", ".ripple-3"], { opacity: 1 }, RIPPLE_AT);
|
||||
tl.to(
|
||||
[".ripple-1", ".ripple-2", ".ripple-3"],
|
||||
{
|
||||
scale: RIPPLE_SCALE,
|
||||
opacity: 0,
|
||||
duration: RIPPLE_DUR,
|
||||
ease: RIPPLE_EASE,
|
||||
stagger: RIPPLE_STAGGER,
|
||||
immediateRender: false,
|
||||
},
|
||||
RIPPLE_AT,
|
||||
);
|
||||
|
||||
window.__timelines["cursor-click-scene"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
- **MOVE_DUR** — cursor travel time from entry to target, in seconds
|
||||
- Range: 0.4–1.0 s
|
||||
- Effects: short feels darting; long feels deliberate / "considered click"
|
||||
- Constraints: must end before `CLICK_AT` — otherwise the click fires while the cursor is still moving and reads as a misclick
|
||||
- Reference: ../../examples/cta-orbit-collapse.html uses 0.5 s
|
||||
|
||||
- **MOVE_EASE** — easing family for the move tween
|
||||
- Discrete choice. Options:
|
||||
- `power2.inOut` — symmetric, calm; good for "the user thoughtfully moves the cursor"
|
||||
- `back.out(<n>)` — overshoot landing; good when the click target is a button you want the cursor to "settle onto" with a tiny visible recoil. Pair with a low overshoot coefficient (~1.2–1.4) — higher reads as cartoonish
|
||||
- `power3.out` — fast start, soft landing; good for a "decisive" move
|
||||
- Reference: ../../examples/cta-orbit-collapse.html uses `back.out(1.3)`
|
||||
|
||||
- **CLICK_AT** — time the click fires, in seconds
|
||||
- Range: must be ≥ `MOVE_DUR` (cursor has settled); typically `MOVE_DUR + 0.0–0.3 s` of "decision pause"
|
||||
- Effects: zero pause reads as autopilot; >0.3 s of pause reads as hesitation
|
||||
- Reference: ../../examples/cta-orbit-collapse.html clicks 0.2 s after the cursor settles
|
||||
|
||||
- **PRESS_DUR** — half-duration of the depression (the yoyo runs twice this)
|
||||
- Range: 0.06–0.12 s
|
||||
- Effects: short feels crisp; long feels mushy
|
||||
- Constraints: total press = `2 * PRESS_DUR`; must finish before the next scene phase needs the cursor / target back at normal scale
|
||||
- Reference: ../../examples/cta-orbit-collapse.html uses 0.08 s
|
||||
|
||||
- **CURSOR_PRESS_SCALE / TARGET_PRESS_SCALE** — how far each compresses during the click
|
||||
- Range: cursor 0.80–0.90; target 0.92–0.97
|
||||
- Effects: smaller numbers = stronger "this click counts" feel; values close to 1 read as a gentle tap
|
||||
- Constraints: cursor compresses MORE than the target — the cursor is the actor, the target is the recipient
|
||||
- Reference: ../../examples/cta-orbit-collapse.html uses cursor 0.85 / target 0.95
|
||||
|
||||
- **RIPPLE_AT** — when the rings start expanding, in seconds
|
||||
- Range: `CLICK_AT + 0.0–0.08 s`
|
||||
- Effects: simultaneous with the press feels causal; slight delay feels acoustic ("the click happens, then the wave radiates")
|
||||
- Reference: ../../examples/cta-orbit-collapse.html starts the ripple at `CLICK_AT` exactly
|
||||
|
||||
- **RIPPLE_DUR** — how long each ring takes to fully expand and fade
|
||||
- Range: 0.5–1.0 s
|
||||
- Effects: short rings feel sharp; long rings feel like a soft sonar
|
||||
- Constraints: must complete before any phase that depends on the ring being gone (e.g. a screen wipe)
|
||||
- Reference: ../../examples/cta-orbit-collapse.html uses 0.7 s
|
||||
|
||||
- **RIPPLE_SCALE** — final scale of each ring before it fades
|
||||
- Range: 3–6
|
||||
- Effects: 3 keeps the ring near the click site; 6 lets it sweep the surrounding area
|
||||
- Constraints: if the ring would exit the visible frame before opacity reaches 0, lower the scale or shorten the duration
|
||||
- Reference: ../../examples/cta-orbit-collapse.html uses 5
|
||||
|
||||
- **RIPPLE_STAGGER** — delay between consecutive rings
|
||||
- Range: 0.06–0.12 s (or 0 for a single ring; see Variations)
|
||||
- Effects: below ~0.06 s reads as one thick ring; above ~0.12 s reads as separate events
|
||||
- Reference: ../../examples/cta-orbit-collapse.html uses a single ring (no stagger)
|
||||
|
||||
- **RIPPLE_EASE** — easing family for the expansion
|
||||
- Discrete choice. Options:
|
||||
- `power2.out` — fast start, soft tail; the standard "ping" feel
|
||||
- `power3.out` — even sharper attack, longer tail
|
||||
- `expo.out` — almost-instant expansion with a long quiet fade; reads as a strong, distant pulse
|
||||
- Reference: ../../examples/cta-orbit-collapse.html uses `power2.out`
|
||||
|
||||
- **TARGET_X / TARGET_Y** — pixel offset of the click target from the cursor's CSS-laid origin
|
||||
- These are layout-derived, not creative knobs — they must match the visual centroid of the actual click target. A 4 px miss reads as missing the button
|
||||
- Reference: ../../examples/cta-orbit-collapse.html targets the white button at `CENTER_X + 130, CENTER_Y + 15`
|
||||
|
||||
## Variations
|
||||
|
||||
- **Single ring** — keep one `.ripple` element, drop the stagger; reads as more elegant when the rest of the scene is busy
|
||||
- **Keyframed attack-decay** — replace the simple expand-and-fade with a `keyframes` block that ramps opacity 0 → peak → 0 across the duration; gives a clearer "energy radiates and dissipates" envelope (used in ../../examples/cta-orbit-collapse.html)
|
||||
- **Multi-ring expanding pulse** — 3 rings with 0.08 s stagger feels richer when the click is the climactic moment of the scene
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Move before click**: trigger the click only after the move tween has settled — clicking mid-motion reads as unintentional
|
||||
- **Synchronized depression**: cursor + target depress at the same `position` time with the same duration (and both yoyo back)
|
||||
- **Ripple from click point**: ripples expand from the exact click location (the button's visual center), not from any element's bounding-box origin
|
||||
- **Subtle scale**: cursor compresses more than the target — see `CURSOR_PRESS_SCALE` / `TARGET_PRESS_SCALE`
|
||||
- **High z-index cursor**: cursor renders above all content for the entire sequence
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Timeline must be paused**: `gsap.timeline({ paused: true })`. Never call `tl.play()` — HyperFrames seeks the timeline frame-by-frame deterministically
|
||||
- **Registry key = `data-composition-id`**: `window.__timelines["<id>"]` must match the `data-composition-id` on the scene root exactly
|
||||
- **`immediateRender: false` on the ripple expand**: holds the initial state (`scale: 0`, `opacity: 0`) until the click moment, otherwise the tween pre-renders and the rings appear at the wrong size at t=0
|
||||
- **Finite duration**: verify `tl.duration()` matches the scene's `data-duration`
|
||||
- **`pointer-events: none` on cursor + ripples**: they're purely visual; never block underlying interactivity (matters for hover-able exports)
|
||||
- **No CSS transitions / animations**: all motion lives in the GSAP timeline so seek stays deterministic
|
||||
|
||||
## Combinations
|
||||
|
||||
- [orbit-3d-entry.md](orbit-3d-entry.md) — when the click is the pivot that collapses orbiting elements toward the cursor's target
|
||||
- [center-outward-expansion.md](center-outward-expansion.md) — the click can be the trigger for an outward burst from the click point
|
||||
- [press-release-spring.md](press-release-spring.md) for stronger physical feel on the target button
|
||||
- [scale-swap-transition.md](scale-swap-transition.md) for the button's state change after click (button morphs into success state, next view, etc.)
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — timeline + tween API reference (eases, stagger, `immediateRender`, etc.)
|
||||
- `/hyperframes-core` — composition wiring (`data-*` attributes, scene structure, registration contract)
|
||||
- `/hyperframes-cli` — `hyperframes lint` to verify the registry key + duration match
|
||||
@@ -0,0 +1,313 @@
|
||||
---
|
||||
name: depth-of-field-blur
|
||||
description: Selective-focus rack-focus — pull the eye to a focal element by GSAP-tweening filter blur (+ a small opacity dim) on the off-focus layers while the focal one stays sharp. Drive blur via a `--dof` CSS var; finite tweens, no CSS transition, deterministic. Covers single focal pull, rack-focus between two depth planes, and blur-the-cluster-while-pushing-in.
|
||||
metadata:
|
||||
tags: blur, focus, depth-of-field, dof, rack-focus, filter, dim, spotlight, cinematic, push-in
|
||||
---
|
||||
|
||||
# Depth-of-Field Blur (Selective Focus / Rack Focus)
|
||||
|
||||
Pulls the eye to one focal element by **blurring** (and slightly **dimming**) everything around it while the focal layer stays sharp — the camera's depth-of-field falling off the background, or a rack-focus shifting which plane is in focus. The motion is `filter: blur(Npx)` plus a small `opacity` dim, tweened from sharp(0) to blurred over the focus-shift window — both seek-safe, since `filter` and `opacity` are paint-only properties HF interpolates correctly frame-by-frame.
|
||||
|
||||
This is the backing rule for the focus-falloff beat the blueprints keep reaching for: the outer nodes blurring during the push-in (`constellation-hub`), the rack-focus across a parallax card stack (`cursor-ui-demo`), and the non-highlighted cards dimming + blurring to spotlight the hero metric (`dataviz-countup`). Each of those flags "no backing rule" for the DoF half of the move — this is it.
|
||||
|
||||
## How It Works
|
||||
|
||||
Every layer carries a `--dof` custom property (px of blur), read by `filter: blur(var(--dof))`, plus its own `opacity`. A single GSAP tween advances each layer's `--dof` from `0` to its target blur and its opacity from `1` to a dim level, over the focus-shift window. The focal layer's tween targets `--dof: 0` (stays sharp); the off-focus layers target a positive blur.
|
||||
|
||||
Three mechanics, same primitive:
|
||||
|
||||
1. **Focal pull** — one window: off-focus layers go sharp(0) → blurred while the focal layer holds at 0. The eye is pulled to the only thing still crisp.
|
||||
2. **Rack focus** — two adjacent windows on the same property: focus releases plane A (its blur ramps 0 → max) at the same position plane B's blur ramps max → 0. State continuity matters exactly as in `press-release-spring`: A's resting blur after the rack must be the value B held before it, so authoring the two as adjacent tweens on the same `--dof` is what makes the hand-off seamless.
|
||||
3. **Blur-the-cluster-while-pushing-in** — the DoF tween runs concurrently with a camera push-in (`multi-phase-camera` / `coordinate-target-zoom`): the surrounding cluster blurs + dims on the SAME timeline position as the camera scales toward the focal core, so "the world recedes" and "we push in" read as one move.
|
||||
|
||||
Because the blur is a tween target (not a CSS `transition`), the renderer can land it at any frame — and because each layer's target is derived from its index / a data attribute (never `Math.random`), the falloff is identical on every seek.
|
||||
|
||||
## HTML
|
||||
|
||||
```html
|
||||
<div
|
||||
class="scene"
|
||||
id="dof-scene"
|
||||
data-composition-id="dof-scene"
|
||||
data-start="0"
|
||||
data-duration="DURATION"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div class="world" id="world">
|
||||
<!-- Focal layer — stays sharp -->
|
||||
<div class="layer focal" id="focal" data-dof="0">{FocalLabel}</div>
|
||||
|
||||
<!-- Off-focus layers — blur + dim. data-depth orders them near→far
|
||||
so the falloff can scale blur by depth (see Variations). -->
|
||||
<div class="layer ctx" data-depth="1">{Context A}</div>
|
||||
<div class="layer ctx" data-depth="2">{Context B}</div>
|
||||
<div class="layer ctx" data-depth="3">{Context C}</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS
|
||||
|
||||
```css
|
||||
.scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: {bgGradient};
|
||||
}
|
||||
.world {
|
||||
/* Single wrapper so a concurrent camera push-in (multi-phase-camera)
|
||||
transforms everything together; DoF is independent of the camera. */
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
.layer {
|
||||
/* --dof is the px of blur; filter reads it. Starts sharp. */
|
||||
--dof: 0px;
|
||||
filter: blur(var(--dof));
|
||||
/* will-change: filter — promotes the layer so the blur is cheap to
|
||||
re-rasterize each frame. See the perf note in Key Principles. */
|
||||
will-change: filter;
|
||||
font-family: {font};
|
||||
font-weight: 900;
|
||||
color: {textColor};
|
||||
}
|
||||
.focal {
|
||||
/* Sits above the context layers and never blurs. */
|
||||
z-index: 2;
|
||||
font-size: FOCAL_FONT_SIZE;
|
||||
}
|
||||
.ctx {
|
||||
/* The off-focus plane(s). Smaller / grouped so the blur radius can
|
||||
stay modest yet still read — blurring a small layer is cheap. */
|
||||
z-index: 1;
|
||||
font-size: CTX_FONT_SIZE;
|
||||
opacity: 1;
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
const ctx = gsap.utils.toArray(".ctx");
|
||||
|
||||
// ── Mechanic 1: FOCAL PULL ─────────────────────────────────────────
|
||||
// Off-focus layers blur + dim from sharp to defocused over the
|
||||
// focus-shift window. Blur scales with data-depth so far planes blur
|
||||
// more than near ones (deterministic — derived from the attribute,
|
||||
// never Math.random).
|
||||
ctx.forEach((el) => {
|
||||
const depth = Number(el.dataset.depth) || 1;
|
||||
const targetBlur = BLUR_PER_DEPTH * depth; // px
|
||||
tl.to(
|
||||
el,
|
||||
{
|
||||
"--dof": `${targetBlur}px`,
|
||||
opacity: DIM_LEVEL, // e.g. 0.55 — dim, not gone
|
||||
duration: FOCUS_DUR,
|
||||
ease: "power2.inOut",
|
||||
},
|
||||
FOCUS_START,
|
||||
);
|
||||
});
|
||||
// Focal layer is already sharp (--dof:0, opacity:1) and untouched.
|
||||
|
||||
window.__timelines["dof-scene"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
### Rack focus between two depth planes (foreground ⇄ background)
|
||||
|
||||
Two adjacent tweens on the same `--dof` per plane — focus leaves plane A as it lands on plane B. State continuity: B's _resting_ blur before the rack equals what A holds after, so the hand-off has no jump.
|
||||
|
||||
```js
|
||||
// Start: A sharp, B pre-blurred (set BEFORE the rack so there's no pop).
|
||||
gsap.set("#planeA", { "--dof": "0px", opacity: 1 });
|
||||
gsap.set("#planeB", { "--dof": `${MAX_BLUR}px`, opacity: DIM_LEVEL });
|
||||
|
||||
// Rack: A defocuses while B comes into focus, same position + duration.
|
||||
tl.to(
|
||||
"#planeA",
|
||||
{ "--dof": `${MAX_BLUR}px`, opacity: DIM_LEVEL, duration: RACK_DUR, ease: "power2.inOut" },
|
||||
RACK_START,
|
||||
);
|
||||
tl.to(
|
||||
"#planeB",
|
||||
{ "--dof": "0px", opacity: 1, duration: RACK_DUR, ease: "power2.inOut" },
|
||||
RACK_START,
|
||||
);
|
||||
```
|
||||
|
||||
### Blur the cluster while pushing in (DoF + camera, one beat)
|
||||
|
||||
Run the focal-pull tween at the **same timeline position** as a camera push-in so the surrounding cluster recedes into blur exactly as the camera scales toward the core. The camera transforms `.world`; the DoF tweens the layers — independent properties, no conflict.
|
||||
|
||||
```js
|
||||
// Camera push-in toward the focal core (see multi-phase-camera / coordinate-target-zoom).
|
||||
tl.to(
|
||||
"#world",
|
||||
{ scale: PUSH_SCALE, x: PUSH_X, y: PUSH_Y, duration: FOCUS_DUR, ease: "power2.inOut" },
|
||||
FOCUS_START,
|
||||
);
|
||||
// Cluster blurs + dims on the SAME position — "the world recedes as we push in."
|
||||
ctx.forEach((el) => {
|
||||
const depth = Number(el.dataset.depth) || 1;
|
||||
tl.to(
|
||||
el,
|
||||
{
|
||||
"--dof": `${BLUR_PER_DEPTH * depth}px`,
|
||||
opacity: DIM_LEVEL,
|
||||
duration: FOCUS_DUR,
|
||||
ease: "power2.inOut",
|
||||
},
|
||||
FOCUS_START,
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### Spotlight a hero metric in a card grid (dim + blur the rest)
|
||||
|
||||
The `dataviz-countup` beat: a subset of grid cards stays sharp (the hero metric) while the remainder dim + blur. Tag the hero(es) and skip them; everything else defocuses on one shared window.
|
||||
|
||||
```js
|
||||
gsap.utils.toArray(".card:not(.hero)").forEach((el) => {
|
||||
tl.to(
|
||||
el,
|
||||
{ "--dof": `${GRID_BLUR}px`, opacity: DIM_LEVEL, duration: FOCUS_DUR, ease: "power2.out" },
|
||||
FOCUS_START,
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### Refocus / settle (release the blur before the scene ends)
|
||||
|
||||
If the beat resolves back to "everything visible" (or hands off to a crossfade that needs a clean outgoing frame), ramp the blur back to 0 over the tail so the scene settles sharp instead of mid-defocus.
|
||||
|
||||
```js
|
||||
ctx.forEach((el) =>
|
||||
tl.to(
|
||||
el,
|
||||
{ "--dof": "0px", opacity: 1, duration: REFOCUS_DUR, ease: "power2.inOut" },
|
||||
REFOCUS_START,
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
### Bounded focus-breathing on the focal layer (optional)
|
||||
|
||||
For a subtle "rack settling" feel, let the focal layer's blur breathe a hair around 0 during its hold — a _finite_ `ease:"none"` driver writing `sin()` into `--dof` (never `repeat:-1`, never a CSS animation). Keep the amplitude well under 1px or it reads as "still focusing."
|
||||
|
||||
```js
|
||||
const drift = { p: 0 };
|
||||
tl.to(
|
||||
drift,
|
||||
{
|
||||
p: Math.PI * 2 * BREATH_CYCLES,
|
||||
duration: BREATH_DUR,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
const b = Math.max(0, Math.sin(drift.p)) * FOCAL_BREATH_PX; // ≤ ~0.6px
|
||||
document.getElementById("focal").style.setProperty("--dof", `${b}px`);
|
||||
},
|
||||
},
|
||||
BREATH_START,
|
||||
);
|
||||
```
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
### Geometry / layout
|
||||
|
||||
- **FOCAL_FONT_SIZE / CTX_FONT_SIZE** — focal vs context sizing.
|
||||
- Range: focal is the visual lead; context layers smaller so a modest blur radius still reads as "out of focus."
|
||||
- Effects: small context layers let you use a smaller `BLUR_PER_DEPTH` (cheaper) yet still look soft.
|
||||
- **z-index** — focal `z-index: 2`, context `z-index: 1`.
|
||||
- Constraints: the sharp focal layer must sit **above** the blurred ones, or its crisp edges read as bleeding into the haze.
|
||||
|
||||
### Blur amounts
|
||||
|
||||
- **BLUR_PER_DEPTH** — px of blur added per depth step (`data-depth`).
|
||||
- Range: 3-6 px per step (a 3-plane stack tops out at ~9-18 px)
|
||||
- Effects: low → gentle DoF; high → strong miniature/tilt-shift falloff
|
||||
- Constraints: keep **per-layer blur ≤ ~24 px on large layers** — radius cost grows with both blur and area; large radius over a full-frame element is the expensive case (see Key Principles)
|
||||
- **MAX_BLUR** — terminal blur for a fully-defocused plane (rack / focal-pull peak).
|
||||
- Range: 8 (soft) → 16 (default) → 24 (heavy) px
|
||||
- Constraints: above ~24 px on a big surface, prefer scaling the layer down or grouping its contents so the blurred footprint shrinks
|
||||
- **GRID_BLUR** — blur on dimmed grid cards (spotlight variation).
|
||||
- Range: 6-12 px — enough to push them back without losing the grid's shape
|
||||
|
||||
### Dim amounts
|
||||
|
||||
- **DIM_LEVEL** — opacity of off-focus layers at full defocus.
|
||||
- Range: 0.4 (strong push-back) → 0.55 (default) → 0.7 (subtle)
|
||||
- Effects: lower → context recedes hard / near-spotlight; higher → still legibly present, just secondary
|
||||
- Constraints: rarely below 0.35 — fully dark off-focus layers read as "removed," not "defocused"
|
||||
|
||||
### Timing
|
||||
|
||||
- **FOCUS_START / FOCUS_DUR** — when the focal pull begins and how long the rack takes.
|
||||
- Range: `FOCUS_DUR` 0.5-1.2 s — a rack/pull is a deliberate move, not a snap
|
||||
- Effects: shorter → urgent "snap focus"; longer → languid cinematic rack
|
||||
- **RACK_START / RACK_DUR** — rack-focus window (foreground ⇄ background).
|
||||
- Constraints: both planes' tweens share `RACK_START` and `RACK_DUR` so they cross at the midpoint; `gsap.set` the pre-blurred plane BEFORE `RACK_START`
|
||||
- **REFOCUS_START / REFOCUS_DUR** — settle-back window.
|
||||
- Constraints: `REFOCUS_START + REFOCUS_DUR ≤ DURATION` so the scene actually reaches sharp before it ends / hands off
|
||||
- **PUSH_SCALE / PUSH_X / PUSH_Y** (cluster-while-pushing-in variation) — camera move on `.world`.
|
||||
- Constraints: shares `FOCUS_START` + `FOCUS_DUR` with the DoF tween so move and defocus read as one beat; counter-translate math lives in `coordinate-target-zoom` / `viewport-change`
|
||||
- **BREATH_CYCLES / BREATH_DUR / FOCAL_BREATH_PX** (focus-breathing variation).
|
||||
- Range: `FOCAL_BREATH_PX ≤ 0.6` px; period 2-3 s; this is a barely-there nicety, default to omitting it
|
||||
|
||||
### Tokens
|
||||
|
||||
- **{bgGradient}** — typically dark so the sharp focal layer reads as lit and forward
|
||||
- **{textColor}** — high-contrast on `{bgGradient}`; the blur softens edges, so don't rely on hairline contrast
|
||||
- **{font}** — display weight; blurred copy needs heavy weight to stay shape-legible when defocused
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **`--dof` drives the blur; tween the variable, never a CSS `transition`.** Reading `filter: blur(var(--dof))` and animating `--dof` on the GSAP timeline keeps the blur on the HF seek clock. A CSS `transition` on `filter` interpolates on the browser's own clock and flickers/desyncs under frame-by-frame seek.
|
||||
- **Blur the SMALL / GROUPED layers, not the giant one.** Filter-blur cost scales with both radius and the blurred element's pixel area. A 20 px blur on a full-frame background is the worst case; the same blur on a smaller context card, or on a single grouped wrapper, is cheap. Prefer pushing the focal plane _forward and sharp_ over cranking the background blur radius.
|
||||
- **`will-change: filter`** on every layer that animates its blur — promotes it to its own layer so the re-rasterization each frame is cheap. Drop it once the blur settles if the layer also does heavy transform work.
|
||||
- **Keep the radius modest.** ≤ ~24 px on large surfaces; lean on the `opacity` **dim** to do the "push it back" work alongside a smaller blur, rather than blur alone. Dim + modest blur reads more like real DoF than blur cranked to the max.
|
||||
- **Focal layer stays genuinely sharp** — its `--dof` is `0` and untouched (or breathes ≤0.6 px). Any visible blur on the focal element kills the "this is the thing" read.
|
||||
- **State continuity on a rack** — the plane coming OUT of focus must start the rack at the blur the incoming plane _was_ holding, and vice-versa; author both as tweens on the same `--dof` at the same position so the cross is seamless (same rule as `press-release-spring`'s press↔release).
|
||||
- **DoF is independent of the camera** — blur the layers, transform `.world` for the push-in. They're different property channels, so they compose without fighting. Don't try to fake DoF with the camera transform or vice-versa.
|
||||
- **Settle sharp before a hand-off** — if the next beat is a crossfade/push, refocus to `--dof:0` in the tail so the outgoing frame is crisp; handing off mid-defocus reads as "the render glitched."
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Timeline must be paused**: `gsap.timeline({ paused: true })`
|
||||
- **Registry key = `data-composition-id`**
|
||||
- **No CSS `transition`** on `filter` / `opacity` — animate `--dof` and `opacity` on the timeline instead
|
||||
- **No `repeat` / `yoyo` / infinite tweens** — the focus pull is a finite tween; any breathing is a bounded `onUpdate` reading the driver phase (or a finite tween), never `repeat:-1`
|
||||
- **No `Math.random` / `Date.now`** — per-layer blur is derived from `data-depth` / element index so every seek is identical
|
||||
- **Tween `filter` (blur) + `opacity` only here** — both paint-only and seek-safe. Use GSAP transform aliases (`x`, `y`, `scale`) for any concurrent camera move; never tween `width` / `height` / `left` / `top`
|
||||
- **`will-change: filter`** on layers whose blur animates; keep the blurred footprint small
|
||||
- **Per-layer blur radius ≤ ~24 px on large surfaces** — beyond that the cost (and visible banding) climbs; shrink/group the layer instead
|
||||
|
||||
## Combinations
|
||||
|
||||
- [multi-phase-camera.md](multi-phase-camera.md) — the push-in / push-through whose focus-falloff this rule supplies; run the DoF tween at the same position as the PUSH phase
|
||||
- [coordinate-target-zoom.md](coordinate-target-zoom.md) — zoom onto the focal core while the off-center layers blur (the `constellation-hub` hook)
|
||||
- [viewport-change.md](viewport-change.md) — pan across a tilted card plane with a rack-focus between near and far cards (the `cursor-ui-demo` focus-pull)
|
||||
- [counting-dynamic-scale.md](counting-dynamic-scale.md) — the hero metric counts up sharp while the surrounding cards dim + blur (the `dataviz-countup` spotlight)
|
||||
- [3d-page-scroll.md](3d-page-scroll.md) — the parallax card stack whose planes you rack focus between
|
||||
- [sine-wave-loop.md](sine-wave-loop.md) — the focal layer idle-breathes after the rack settles (keep idle amplitude and focus-breath both tiny)
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — tweening a CSS custom property + multi-tween coordination
|
||||
- `/hyperframes-core` — composition wiring
|
||||
- `/hyperframes-cli` — `hyperframes lint`
|
||||
@@ -0,0 +1,303 @@
|
||||
---
|
||||
name: depth-scatter-assemble
|
||||
description: N elements scatter into / reassemble from a rotating 3D depth-cloud, each starting at a deterministic index-derived 3D offset and settling to a clean flat layout.
|
||||
metadata:
|
||||
tags: 3d, scatter, assemble, depth, cloud, tumble, kinetic, letter, fragment, logo, reassemble
|
||||
---
|
||||
|
||||
# Depth Scatter ↔ Assemble
|
||||
|
||||
N elements (glyphs, cards, icons, logo fragments) fly in from a rotating 3D depth-cloud and lock into a clean on-screen layout — or the reverse. Each element starts at a **deterministic** 3D offset (translateZ depth + rotateX/rotateY + an x/y scatter derived from its index), then tweens to its assembled flat position (`z: 0, rotation: 0`). Because every scattered position is computed by trig on the element's index — never `Math.random` — it renders identically every frame.
|
||||
|
||||
Distinct from `orbit-3d-entry` (flip-in then a continuous orbit) and `center-outward-expansion` (a flat 2D burst from one shared center): here each element has its **own** point in a 3D cloud, and the resolve is a flat assembled layout, not an orbit or a radial spray.
|
||||
|
||||
## How It Works
|
||||
|
||||
Each element resolves to a flat layout position (`targetX/Y`, set once in CSS or via `data-*`). Its **scattered** state is derived from its index `i`:
|
||||
|
||||
```js
|
||||
const GOLDEN = Math.PI * (3 - Math.sqrt(5)); // ~2.39943 rad — even angular spread, no clumping
|
||||
const a = i * GOLDEN; // this element's angle in the cloud
|
||||
const scatterX = Math.cos(a) * RADIUS; // index-derived, deterministic
|
||||
const scatterY = Math.sin(a) * RADIUS;
|
||||
const scatterZ = Z_NEAR - (i / (n - 1)) * (Z_NEAR - Z_FAR); // stepped depth across the cloud
|
||||
const rotX = Math.sin(a) * TUMBLE; // tumble orientation, also from the angle
|
||||
const rotY = Math.cos(a) * TUMBLE;
|
||||
```
|
||||
|
||||
A single 0→1 `progress` proxy interpolates each element between scattered and assembled (lerp every channel). At `progress = 0` the elements form the depth-cloud; at `progress = 1` they sit flat in the layout. Run it forward and it's **assemble**; the cloud itself slowly rotates (a stage `rotateY` tween) so the scatter has life before it locks.
|
||||
|
||||
Requires `perspective` on the stage and `transform-style: preserve-3d` on the stage AND each element, or the z-depth and tumble flatten to a 2D scale.
|
||||
|
||||
## HTML
|
||||
|
||||
```html
|
||||
<div
|
||||
class="scene"
|
||||
id="assemble-scene"
|
||||
data-composition-id="assemble-scene"
|
||||
data-start="0"
|
||||
data-duration="4"
|
||||
data-track-index="0"
|
||||
>
|
||||
<!-- The cloud rotates; the layout lives inside it. targetX/Y = each
|
||||
element's FLAT assembled offset from stage center (px). -->
|
||||
<div class="cloud-stage">
|
||||
<div class="frag" data-target-x="-260" data-target-y="0">{glyph1}</div>
|
||||
<div class="frag" data-target-x="-130" data-target-y="0">{glyph2}</div>
|
||||
<div class="frag" data-target-x="0" data-target-y="0">{glyph3}</div>
|
||||
<div class="frag" data-target-x="130" data-target-y="0">{glyph4}</div>
|
||||
<div class="frag" data-target-x="260" data-target-y="0">{glyph5}</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
For a logo lockup, `targetX/Y` describe the parts' resting layout; for kinetic type, one `.frag` per glyph (inject spans from the phrase string at setup so width is exact — see Variations).
|
||||
|
||||
## CSS
|
||||
|
||||
```css
|
||||
.scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: {bgColor};
|
||||
perspective: 1400px; /* REQUIRED — without it, z-depth + tumble read as flat 2D scale */
|
||||
}
|
||||
.cloud-stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transform-style: preserve-3d; /* REQUIRED — preserves child 3D context */
|
||||
will-change: transform;
|
||||
}
|
||||
.frag {
|
||||
position: absolute;
|
||||
/* Live at stage center; GSAP translates each one to its layout / cloud point. */
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-family: {font};
|
||||
font-weight: 900;
|
||||
font-size: 120px;
|
||||
color: {textColor};
|
||||
transform-style: preserve-3d; /* each fragment keeps its own 3D context */
|
||||
backface-visibility: hidden; /* hides the mirrored face mid-tumble */
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
const frags = Array.from(document.querySelectorAll(".frag"));
|
||||
const n = frags.length;
|
||||
const GOLDEN = Math.PI * (3 - Math.sqrt(5)); // ~2.39943 — even spread, no clumps
|
||||
|
||||
// RADIUS, Z_NEAR, Z_FAR, TUMBLE, ASSEMBLE_DUR, ASSEMBLE_EASE, STAGGER,
|
||||
// CLOUD_SPIN_DEG, CLOUD_SPIN_DUR — named constants per "How to Choose Values".
|
||||
|
||||
// Precompute each fragment's deterministic scattered state from its index.
|
||||
const scatter = frags.map((el, i) => {
|
||||
const a = i * GOLDEN;
|
||||
const depthT = n > 1 ? i / (n - 1) : 0;
|
||||
return {
|
||||
x: Math.cos(a) * RADIUS,
|
||||
y: Math.sin(a) * RADIUS,
|
||||
z: Z_NEAR - depthT * (Z_NEAR - Z_FAR),
|
||||
rotationX: Math.sin(a) * TUMBLE,
|
||||
rotationY: Math.cos(a) * TUMBLE,
|
||||
};
|
||||
});
|
||||
|
||||
// 1) Park every fragment in the cloud BEFORE any tween fires.
|
||||
frags.forEach((el, i) => {
|
||||
const s = scatter[i];
|
||||
gsap.set(el, {
|
||||
xPercent: -50,
|
||||
yPercent: -50, // bake self-centering so x/y are offsets from stage center
|
||||
x: s.x,
|
||||
y: s.y,
|
||||
z: s.z,
|
||||
rotationX: s.rotationX,
|
||||
rotationY: s.rotationY,
|
||||
opacity: 0,
|
||||
});
|
||||
});
|
||||
|
||||
// 2) The cloud rotates so the scatter has life before / during assembly.
|
||||
tl.to(
|
||||
".cloud-stage",
|
||||
{ rotationY: CLOUD_SPIN_DEG, duration: CLOUD_SPIN_DUR, ease: "power1.out" },
|
||||
0,
|
||||
);
|
||||
|
||||
// 3) ASSEMBLE — each fragment tweens from its cloud point to its flat target.
|
||||
frags.forEach((el, i) => {
|
||||
tl.to(
|
||||
el,
|
||||
{
|
||||
x: Number(el.dataset.targetX),
|
||||
y: Number(el.dataset.targetY),
|
||||
z: 0,
|
||||
rotationX: 0,
|
||||
rotationY: 0,
|
||||
opacity: 1,
|
||||
duration: ASSEMBLE_DUR,
|
||||
ease: ASSEMBLE_EASE, // out-ease — fragments fly in then settle
|
||||
},
|
||||
i * STAGGER, // index stagger reads as "cloud collapsing inward"
|
||||
);
|
||||
});
|
||||
|
||||
window.__timelines["assemble-scene"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
### Tumble-swap (mid-shot hand-off between two phrases)
|
||||
|
||||
The signature for `kinetic-type-beats` beat changes: one phrase's glyphs scatter **into** the cloud at the same moment the next phrase's glyphs assemble **out** of it — a 3D hand-off between two states, never an empty frame. Two glyph sets share the cloud; drive both with one shared 0→1 `progress` so they cross deterministically.
|
||||
|
||||
```js
|
||||
// outgoing[] and incoming[] are two glyph arrays, each with precomputed scatter[] (above).
|
||||
const swap = { p: 0 };
|
||||
tl.to(
|
||||
swap,
|
||||
{
|
||||
p: 1,
|
||||
duration: SWAP_DUR,
|
||||
ease: "power2.inOut",
|
||||
onUpdate: () => {
|
||||
const p = swap.p;
|
||||
outgoing.forEach((el, i) => {
|
||||
// 1 → 0: layout → cloud (scatters AWAY)
|
||||
const s = outScatter[i];
|
||||
const tx = Number(el.dataset.targetX);
|
||||
const ty = Number(el.dataset.targetY);
|
||||
el.style.opacity = String(1 - p);
|
||||
el.style.transform =
|
||||
`translate(-50%,-50%) translate3d(${tx + (s.x - tx) * p}px,${ty + (s.y - ty) * p}px,${s.z * p}px)` +
|
||||
` rotateX(${s.rotationX * p}deg) rotateY(${s.rotationY * p}deg)`;
|
||||
});
|
||||
incoming.forEach((el, i) => {
|
||||
// 0 → 1: cloud → layout (assembles IN)
|
||||
const s = inScatter[i];
|
||||
const tx = Number(el.dataset.targetX);
|
||||
const ty = Number(el.dataset.targetY);
|
||||
el.style.opacity = String(p);
|
||||
el.style.transform =
|
||||
`translate(-50%,-50%) translate3d(${s.x + (tx - s.x) * p}px,${s.y + (ty - s.y) * p}px,${s.z * (1 - p)}px)` +
|
||||
` rotateX(${s.rotationX * (1 - p)}deg) rotateY(${s.rotationY * (1 - p)}deg)`;
|
||||
});
|
||||
},
|
||||
},
|
||||
SWAP_AT,
|
||||
);
|
||||
```
|
||||
|
||||
Inject a per-glyph span set for each phrase at setup (so `targetX` per glyph is the exact laid-out advance width — measure after `document.fonts.ready`), and hide each set's opacity to 0 until its window.
|
||||
|
||||
### Radial letter-explode → resolve
|
||||
|
||||
A flat-plane special case (the `kinetic-type-beats` "letters explode radially then resolve" GAP): set `Z_NEAR = Z_FAR = 0` and `TUMBLE` small so the cloud is a 2D ring, then reverse the assemble for the explode — fragments fling out to `scatter[i]` then snap back to layout. Pure in-plane, no depth.
|
||||
|
||||
### Scatter-OUT (final-frame exit only)
|
||||
|
||||
Reverse the assemble (layout → cloud, opacity 1→0) ONLY as the composition's last beat. A scatter-out mid-shot reads as an exit and breaks the shot — keep entrances and hand-offs as assemble or tumble-swap.
|
||||
|
||||
### Parallax depth slide-in (logo lockup)
|
||||
|
||||
For `logo-assemble-lockup`, give back layers a larger `|Z_FAR|` and a longer `ASSEMBLE_DUR`, foreground parts a shallower depth and shorter duration — parts at different depths slide in at different apparent speeds (parallax) and lock into the lockup.
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
- **n (ELEMENT_COUNT)** — fragments / glyphs in the cloud
|
||||
- Range: 4–14 (glyph sets follow the word length; for fragments/cards stay 4–9)
|
||||
- Effects: few reads as deliberate assembly; many reads as a dense swarm condensing
|
||||
- Constraints: above ~14 the cloud crowds the center and individual paths stop reading
|
||||
|
||||
- **RADIUS** — cloud spread in the x/y plane, px
|
||||
- Range: 250–700 px
|
||||
- Effects: small = a tight knot that barely separates; large = fragments arrive from the frame edges
|
||||
- Constraints: keep the farthest scatter inside frame at the chosen `perspective`, or fragments pop in from off-screen with no travel read
|
||||
|
||||
- **Z_NEAR / Z_FAR** — depth band of the cloud, px (front / back)
|
||||
- Range: Z_NEAR +150 to +450; Z_FAR −150 to −500
|
||||
- Effects: a wide band (e.g. +400 / −400) gives strong fly-toward / recede-from camera depth; a narrow band keeps it nearly flat
|
||||
- Constraints: very large `|z|` against a short `perspective` over-distorts (fragments smear huge then tiny) — widen `perspective` to match
|
||||
|
||||
- **TUMBLE** — peak rotateX/rotateY of scattered fragments, deg
|
||||
- Range: 40–110°
|
||||
- Effects: low = fragments drift in nearly upright; high = they tumble through space and rotate upright on arrival
|
||||
- Constraints: with `backface-visibility: hidden`, glyphs past 90° show blank mid-tween (intended for the tumble); for cards with content on one face, cap near 80°
|
||||
|
||||
- **ASSEMBLE_DUR** — per-fragment cloud → layout tween, s
|
||||
- Range: 0.7–1.4 s
|
||||
- Effects: short = snappy lock-in; long = a floating condense
|
||||
- Constraints: `(n − 1) × STAGGER + ASSEMBLE_DUR` must fit the scene's assembly window
|
||||
|
||||
- **ASSEMBLE_EASE** — shared ease across fragments
|
||||
- Discrete choice: `power3.out`, `expo.out`, `back.out(1.4)`
|
||||
- Selection: `power3.out` default (fly in, settle). `expo.out` snaps hard at the end. `back.out` adds a small overshoot as parts seat. Avoid `in` easings — fragments look sucked backward into the cloud mid-air.
|
||||
|
||||
- **STAGGER** — gap between successive fragments' assembly starts, s
|
||||
- Range: 0.03–0.09 s
|
||||
- Effects: < 0.03 = a single chord (whole cloud collapses at once); > 0.09 = a slow drip that loses the "swarm" read
|
||||
- Constraints: `n × STAGGER` should stay below `ASSEMBLE_DUR` so the cloud is collapsing as one motion, not a queue
|
||||
|
||||
- **CLOUD_SPIN_DEG / CLOUD_SPIN_DUR** — stage rotateY over the assembly, deg / s
|
||||
- Range: 15–60° over a duration ≥ `ASSEMBLE_DUR`
|
||||
- Effects: a gentle spin gives the scatter life so it doesn't read as a frozen explosion diagram; too fast competes with the assembly
|
||||
- Constraints: keep finite and ending by settle — no `repeat`
|
||||
|
||||
- **SWAP_DUR / SWAP_AT** (tumble-swap) — hand-off length / when it fires, s
|
||||
- Range: SWAP_DUR 0.5–1.0 s; SWAP_AT on the beat boundary
|
||||
- Effects: shorter = a hard cross; longer = a visible dissolve-through-cloud
|
||||
- Constraints: outgoing and incoming MUST share one `progress` (one tween) so they cross at the same instant
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **`perspective` on the scene root + `preserve-3d` on stage AND each fragment** — without all three, z-depth and tumble collapse to a flat scale
|
||||
- **Every scattered value is index-derived** — `cos/sin(i × GOLDEN)`, stepped `z` by `i/(n−1)`. The golden angle spreads points evenly with no clumps and (critically) **no `Math.random`**, so the cloud is byte-identical every render
|
||||
- **`gsap.set` the cloud BEFORE adding tweens** — park each fragment at its scatter point with `opacity: 0` first; the assemble tweens FROM there. Skipping the set leaves frame 0 showing the assembled layout, then a teleport when the first tween starts
|
||||
- **Resolve flat** — the settled state is `z: 0, rotationX: 0, rotationY: 0` in the layout. A cloud that resolves still-tilted reads as unfinished
|
||||
- **Assemble / hand-off only; scatter-OUT is an exit** — fragments leaving for the cloud mid-shot reads as the shot ending. Use forward assemble for entrances, tumble-swap for beat changes; reserve scatter-out for the final frame
|
||||
- **Depth ordering is automatic** — inside `preserve-3d`, paint order follows actual Z, so nearer fragments correctly occlude farther ones with no manual z-index (unlike the orbit case, where the orbit is faked in 2D and needs capped z-index)
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **No `Math.random` / `Date.now`** — derive every scatter coordinate from the index (golden-angle trig + stepped depth). This is the whole point of the rule: a randomized cloud renders differently each frame and the seek breaks
|
||||
- **No CSS `transition`** — all motion is GSAP tweens on the paused timeline
|
||||
- **No `repeat` / `yoyo` / infinite** — the cloud spin and every assemble are finite, one-shot tweens that end before settle
|
||||
- **Timeline must be paused**: `gsap.timeline({ paused: true })`
|
||||
- **Registry key = `data-composition-id`**
|
||||
- **Transform aliases only** — `x`, `y`, `z`, `scale`, `rotation`/`rotationX`/`rotationY`. Never `width`/`height`/`left`/`top`; `x`/`y` compose with the `xPercent/yPercent -50` self-centering
|
||||
- **`will-change: transform`** on stage + fragments — many simultaneous 3D transforms benefit from compositor hints
|
||||
- **In tumble-swap, one shared `progress` for both glyph sets** — two separate tweens can drift out of phase under seek and the cross stops looking like a single hand-off
|
||||
|
||||
## Combinations
|
||||
|
||||
- [orbit-3d-entry.md](orbit-3d-entry.md) — alternative 3D entrance (settles into a continuous orbit instead of a flat lockup); shares the `perspective` + `preserve-3d` stage setup
|
||||
- [hacker-flip-3d.md](hacker-flip-3d.md) — per-glyph 3D flip/decode as the fragments seat; layer for a "letters tumble in AND decode on arrival" read
|
||||
- [3d-text-depth-layers.md](3d-text-depth-layers.md) — give the assembled wordmark a stacked extrusion once it locks
|
||||
- [center-outward-expansion.md](center-outward-expansion.md) — flat 2D cousin (single shared center, no depth) when perspective isn't wanted
|
||||
- [press-release-spring.md](press-release-spring.md) — a spring settle on the assembled lockup once the cloud resolves
|
||||
- [sine-wave-loop.md](sine-wave-loop.md) — idle breathe on the resolved layout instead of a frozen hold
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — timeline + `onUpdate` API (the shared-progress tumble-swap)
|
||||
- `/hyperframes-core` — composition wiring
|
||||
- `/hyperframes-cli` — `hyperframes lint`
|
||||
@@ -0,0 +1,273 @@
|
||||
---
|
||||
name: discrete-text-sequence
|
||||
description: Replace entire text states at frame thresholds for non-linear typing effects — typos, bulk additions, pauses, backspaces, simulated thinking.
|
||||
metadata:
|
||||
tags: text, typing, discrete, threshold, non-linear, sequence
|
||||
---
|
||||
|
||||
# Discrete Text Sequence
|
||||
|
||||
Instead of character-by-character typewriter, replace entire string states at time thresholds. Enables non-linear effects (typos, bulk additions, pauses, "thinking" gaps) that smooth per-char typing can't achieve.
|
||||
|
||||
## How It Works
|
||||
|
||||
An array of `{ text, t }` pairs where `t` is a time in seconds. On every onUpdate, scan the array for the latest entry whose `t` has passed and render that text. The display jumps between states; no animation between them.
|
||||
|
||||
For continuous per-char typewriter (no pauses, no edits), use the **smooth-slice** variation at the bottom.
|
||||
|
||||
## HTML
|
||||
|
||||
```html
|
||||
<div
|
||||
class="scene"
|
||||
id="seq-scene"
|
||||
data-composition-id="seq-scene"
|
||||
data-start="0"
|
||||
data-duration="6"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div class="terminal">
|
||||
<div class="prompt">$</div>
|
||||
<div class="text-wrap">
|
||||
<span class="text" id="text">|</span>
|
||||
<span class="cursor" id="cursor">_</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS
|
||||
|
||||
```css
|
||||
.scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: {bgColor};
|
||||
font-family: {monoFont}; /* monospace is required — see Critical Constraints */
|
||||
}
|
||||
.terminal {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: GUTTER;
|
||||
font-weight: 800;
|
||||
font-size: TERMINAL_FONT_SIZE;
|
||||
color: {textColor};
|
||||
}
|
||||
.prompt {
|
||||
color: {accentColor};
|
||||
}
|
||||
.text-wrap {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
/* Fixed-width container prevents the right side from jittering as
|
||||
content changes length. Choose width ≥ longest state's width. */
|
||||
min-width: TEXT_WRAP_MIN_WIDTH;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.text {
|
||||
color: {textColor};
|
||||
}
|
||||
.cursor {
|
||||
display: inline-block;
|
||||
width: CURSOR_WIDTH;
|
||||
color: {accentColor};
|
||||
margin-left: CURSOR_GAP;
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline + Discrete State Logic
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
|
||||
// SEQUENCE — each entry shows from t to the NEXT entry's t.
|
||||
// Non-linear: typos, corrections, bulk additions, pauses.
|
||||
// Shape (one realization):
|
||||
// [warm-up keystrokes] → [typo] → [backspaces back to fork] →
|
||||
// [bulk-paste of the corrected continuation] → [completion mark]
|
||||
const SEQUENCE = [
|
||||
{ t: 0.0, text: "" },
|
||||
{ t: T_K1, text: "{p1}" }, // first keystrokes (~3-5 chars, 0.1-0.2s apart)
|
||||
{ t: T_K2, text: "{p1 + ' ' + p2_typo}" }, // continuation containing a typo
|
||||
{ t: T_BS, text: "{p1 + ' ' + p2_partial}" }, // backspace(s) — peel back to the fork
|
||||
{ t: T_BULK, text: "{fullCorrectedText}" }, // bulk paste — replaces several chars at once
|
||||
{ t: T_DONE, text: "{fullCorrectedText + ' ✓'}" }, // completion marker
|
||||
];
|
||||
|
||||
// Reverse-search for the latest entry whose t has passed.
|
||||
function textAt(time) {
|
||||
for (let i = SEQUENCE.length - 1; i >= 0; i--) {
|
||||
if (time >= SEQUENCE[i].t) return SEQUENCE[i].text;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
const textEl = document.getElementById("text");
|
||||
const cursorEl = document.getElementById("cursor");
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// Drive the discrete display via a 0→TOTAL_DURATION tween's onUpdate
|
||||
const driver = { t: 0 };
|
||||
tl.to(
|
||||
driver,
|
||||
{
|
||||
t: TOTAL_DURATION,
|
||||
duration: TOTAL_DURATION,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
textEl.textContent = textAt(driver.t);
|
||||
},
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
// Cursor blink — deterministic via sin, not CSS animation
|
||||
const blinkDriver = { p: 0 };
|
||||
tl.to(
|
||||
blinkDriver,
|
||||
{
|
||||
p: Math.PI * 2 * BLINK_CYCLES, // BLINK_CYCLES = blinks across composition
|
||||
duration: TOTAL_DURATION,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
cursorEl.style.opacity = Math.sin(blinkDriver.p) > 0 ? "1" : "0";
|
||||
},
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
window.__timelines["seq-scene"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
### Smooth character slice (continuous typewriter — no pauses, no edits)
|
||||
|
||||
For straight-forward typewriter without the non-linear chaos:
|
||||
|
||||
```js
|
||||
const fullText = "{fullPhrase}";
|
||||
const len = { v: 0 };
|
||||
tl.to(
|
||||
len,
|
||||
{
|
||||
v: fullText.length,
|
||||
duration: TYPE_DUR,
|
||||
ease: "power1.inOut",
|
||||
onUpdate: () => {
|
||||
textEl.textContent = fullText.substring(0, Math.floor(len.v));
|
||||
},
|
||||
},
|
||||
0,
|
||||
);
|
||||
```
|
||||
|
||||
This is faster to author but produces a uniform "machine-typed" feel — missing the human-typing realism.
|
||||
|
||||
### Thinking pause (extended hold on a key state)
|
||||
|
||||
Insert a state that holds for `THINK_HOLD_DUR` seconds without changes — feels like the user paused to think:
|
||||
|
||||
```js
|
||||
{ t: T_PRE_PAUSE, text: '{partialPhrase}' }, // last state before the pause
|
||||
// ... no entries for THINK_HOLD_DUR seconds ...
|
||||
{ t: T_PRE_PAUSE + THINK_HOLD_DUR, text: '{resumedPhrase}' },
|
||||
```
|
||||
|
||||
### State pulse on completion
|
||||
|
||||
When the final state lands (e.g. "✓"), pulse-scale the line briefly for emphasis:
|
||||
|
||||
```js
|
||||
tl.to(
|
||||
".text",
|
||||
{ scale: COMPLETION_PULSE_SCALE, duration: COMPLETION_PULSE_DUR, yoyo: true, repeat: 1 },
|
||||
T_DONE,
|
||||
);
|
||||
```
|
||||
|
||||
### Per-state color shift
|
||||
|
||||
Color-code states by phase (e.g. dim during edit, success color after the completion marker, optional warning color on typo):
|
||||
|
||||
```js
|
||||
// In onUpdate after setting textContent:
|
||||
if (driver.t > T_DONE) textEl.style.color = "{successColor}";
|
||||
else if (driver.t < T_K2)
|
||||
textEl.style.color = "{textColor}"; // normal typing
|
||||
else textEl.style.color = "{mutedColor}"; // mid-edit dim
|
||||
```
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
### Layout
|
||||
|
||||
- **TERMINAL_FONT_SIZE** — font size of the typing line.
|
||||
- Range: 48-96 px for full-bleed compositions; smaller for terminal-style detail
|
||||
- Constraints: combined with `TEXT_WRAP_MIN_WIDTH` must fit within viewport
|
||||
- **TEXT_WRAP_MIN_WIDTH** — fixed-width container holding the text.
|
||||
- Constraints: must be `≥ widthOf(longest SEQUENCE state) at TERMINAL_FONT_SIZE`. Measure with a hidden probe after `document.fonts.ready` if unsure
|
||||
- Effects: too small → right edge jitters as states change length; too large → unused horizontal whitespace pads the composition
|
||||
- **GUTTER** — flex gap between prompt glyph (`$`, `>`) and text.
|
||||
- Range: ~0.3-0.5× `TERMINAL_FONT_SIZE`
|
||||
- **CURSOR_WIDTH / CURSOR_GAP** — block cursor dimensions.
|
||||
- Range: width ~0.3× `TERMINAL_FONT_SIZE`; gap small (single-digit px) so the cursor feels attached to the text
|
||||
|
||||
### Sequence timing
|
||||
|
||||
- **TOTAL_DURATION** — composition length.
|
||||
- Constraints: must be ≥ `T_DONE` + ~1s climax dwell so viewer sees the completion marker
|
||||
- **T_K1 / T_K2 / T_BS / T_BULK / T_DONE** — milestone timestamps within the SEQUENCE.
|
||||
- Range: keystrokes 0.06-0.20s apart for "human typing"; pauses 0.3-0.6s at natural word breaks; bulk paste jumps multiple characters in a single entry
|
||||
- Constraints: monotonically increasing; `T_DONE ≤ TOTAL_DURATION - dwell`
|
||||
- **TYPE_DUR** (smooth-slice variation) — total typing duration for continuous typewriter.
|
||||
- Range: `chars × 0.06s` (fast) to `chars × 0.12s` (relaxed)
|
||||
- **THINK_HOLD_DUR** (thinking-pause variation) — hold time between two SEQUENCE states.
|
||||
- Range: 0.8-2.0s; under 0.5s reads as a stutter rather than thought
|
||||
- **COMPLETION_PULSE_SCALE / COMPLETION_PULSE_DUR** (pulse variation).
|
||||
- Range: scale 1.03-1.08 (subtle), duration 0.15-0.30s
|
||||
|
||||
### Cursor
|
||||
|
||||
- **BLINK_CYCLES** — number of full blink cycles across `TOTAL_DURATION`.
|
||||
- Range: `TOTAL_DURATION / 0.8s ≤ BLINK_CYCLES ≤ TOTAL_DURATION / 0.5s` (cycle every 0.5-0.8s reads as a natural cursor)
|
||||
|
||||
### Color tokens
|
||||
|
||||
- **{bgColor} / {textColor} / {accentColor} / {successColor} / {mutedColor}** — discrete choices, not numeric ranges. Pick from the composition's palette; the prompt + cursor share `{accentColor}` so they read as the same "system" element.
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Threshold sequence drives realism** — group fast successive keystrokes (0.1-0.2s apart), then pause on word breaks (0.3-0.5s), bulk-paste in single jumps (one entry replaces many chars), include a typo or two for human-typing feel
|
||||
- **Reverse-search the array each frame** — O(n) per frame, where n is small (≤30 typical). Don't try to index by frame; the sequence is sparse
|
||||
- **Fixed-width container is mandatory** — without `min-width`, the right edge of the text wrap jitters as state length changes. Set width ≥ longest expected state
|
||||
- **Cursor must be deterministic** — sin-based or sequence-driven blink, NOT a CSS animation. HF seeks frame-by-frame; CSS animations desync
|
||||
- **No `transition` on the text element** — discrete jumps should be INSTANT. A CSS transition turns the jump into a smear and ruins the "typing" feel
|
||||
- **❗ Distinguish discrete from smooth** — if your effect is "type each character, no edits" → use the smooth-slice variation. Discrete sequence is overkill for that case. Use discrete only when you need non-linear states (typos, pauses, bulk paste)
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Timeline must be paused**: `gsap.timeline({ paused: true })`
|
||||
- **Registry key = `data-composition-id`**
|
||||
- **No CSS `transition`** on the text or any of its parents
|
||||
- **Cursor `display: inline-block`** — `display: inline` ignores width/transform
|
||||
- **Monospace font** for terminal-style effects — proportional fonts cause visual jitter even with fixed-width container
|
||||
- **Whitespace: nowrap** on text wrap — wrapping mid-state breaks the illusion
|
||||
|
||||
## Combinations
|
||||
|
||||
- [3d-text-depth-layers.md](3d-text-depth-layers.md) — discrete text rendered with layered depth (heavy, dramatic)
|
||||
- [counting-dynamic-scale.md](counting-dynamic-scale.md) — discrete text for the LABEL while counter animates smoothly
|
||||
- [press-release-spring.md](press-release-spring.md) — after the sequence completes, the line "presses" like a button confirming success
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — onUpdate-driven discrete state lookup
|
||||
- `/hyperframes-core` — composition wiring
|
||||
- `/hyperframes-cli` — `hyperframes lint`
|
||||
@@ -0,0 +1,307 @@
|
||||
---
|
||||
name: dynamic-content-sequencing
|
||||
description: Auto-calculate timeline start/end times from content length + per-item duration config — longer content gets more screen time without hardcoded numbers.
|
||||
metadata:
|
||||
tags: timeline, sequencing, dynamic, duration, content-aware, utility
|
||||
---
|
||||
|
||||
# Dynamic Content Sequencing
|
||||
|
||||
A utility pattern (not a motion rule in itself) for scenes that show a SEQUENCE of items (cards, phrases, stats). Each item's duration is calculated from its content length + a per-item config; the sequencer assigns absolute start/end times automatically. Distinct from [discrete-text-sequence](discrete-text-sequence.md) (which is one text element changing states) — this rule swaps between distinct content blocks.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Define a content array — each entry has `{ text, speedFactor, hold }` (or arbitrary fields)
|
||||
2. Pre-compute absolute start times: `start[i] = sum of durations 0..i-1`
|
||||
3. In onUpdate, find which entry is active (last entry whose `start ≤ time`) and render it
|
||||
|
||||
The "dynamic" part: items with longer text get more screen time (formula: `baseDuration + textLength * msPerChar`). No hardcoded `from` / `durationInFrames` per item.
|
||||
|
||||
## HTML
|
||||
|
||||
```html
|
||||
<div
|
||||
class="scene"
|
||||
id="seq-scene"
|
||||
data-composition-id="seq-scene"
|
||||
data-start="0"
|
||||
data-duration="{DURATION}"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div class="display">
|
||||
<div class="eyebrow" id="eyebrow">{eyebrow}</div>
|
||||
<div class="title" id="title"></div>
|
||||
<div class="body" id="body"></div>
|
||||
<div class="progress-bar"><div class="progress-fill" id="progress-fill"></div></div>
|
||||
</div>
|
||||
<div class="brand">— {Brand}</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS
|
||||
|
||||
Placeholders: `{font}` is the project sans-serif stack; `{bgColor1}`/`{bgColor2}` make the dark backdrop gradient; `{accentColor}` highlights the eyebrow / brand / progress fill; `{textColor}` is the primary readable foreground.
|
||||
|
||||
```css
|
||||
.scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: radial-gradient(ellipse at center, {bgColor1} 0%, {bgColor2} 70%);
|
||||
font-family: {font};
|
||||
}
|
||||
.display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
text-align: center;
|
||||
max-width: 1400px;
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 14px;
|
||||
color: {accentColor};
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.title {
|
||||
font-size: 120px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -2px;
|
||||
line-height: 1;
|
||||
color: {textColor};
|
||||
}
|
||||
.body {
|
||||
font-size: 48px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
color: {accentColor};
|
||||
opacity: 0.9;
|
||||
min-height: 160px; /* reserve space so layout doesn't jump */
|
||||
}
|
||||
.progress-bar {
|
||||
width: 600px;
|
||||
height: 4px;
|
||||
background: {accentColor}26; /* ~15% alpha */
|
||||
border-radius: 2px;
|
||||
margin-top: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, {accentColor} 0%, {accentColor2} 100%);
|
||||
width: 0%;
|
||||
}
|
||||
.brand {
|
||||
position: absolute;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 32px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 12px;
|
||||
color: {accentColor};
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// Content array — N entries, each with its own pacing config.
|
||||
// Shape: short eyebrow label, short title, longer body sentence, plus per-entry pacing.
|
||||
// The final entry typically uses a larger `hold` (closing beat).
|
||||
const CONTENT = [
|
||||
{
|
||||
eyebrow: "{eyebrow1}",
|
||||
title: "{title1}",
|
||||
body: "{body1}",
|
||||
speedFactor: SPEED_FACTOR,
|
||||
hold: HOLD_MID,
|
||||
},
|
||||
{
|
||||
eyebrow: "{eyebrow2}",
|
||||
title: "{title2}",
|
||||
body: "{body2}",
|
||||
speedFactor: SPEED_FACTOR,
|
||||
hold: HOLD_MID,
|
||||
},
|
||||
// …
|
||||
{
|
||||
eyebrow: "{eyebrowN}",
|
||||
title: "{titleN}",
|
||||
body: "{bodyN}",
|
||||
speedFactor: SPEED_FACTOR,
|
||||
hold: HOLD_FINAL,
|
||||
},
|
||||
];
|
||||
|
||||
// Pre-compute absolute start times.
|
||||
// Duration per entry: BASE_DURATION + body.length * SEC_PER_CHAR + entry.hold seconds.
|
||||
// BASE_DURATION, SEC_PER_CHAR documented in How to Choose Values.
|
||||
let cumulative = 0;
|
||||
const TIMELINE = CONTENT.map((entry) => {
|
||||
const dur = BASE_DURATION + entry.body.length * SEC_PER_CHAR + entry.hold;
|
||||
const start = cumulative;
|
||||
cumulative += dur;
|
||||
return { ...entry, start, end: cumulative };
|
||||
});
|
||||
|
||||
// Reverse-search current entry
|
||||
function entryAt(time) {
|
||||
for (let i = TIMELINE.length - 1; i >= 0; i--) {
|
||||
if (time >= TIMELINE[i].start) return TIMELINE[i];
|
||||
}
|
||||
return TIMELINE[0];
|
||||
}
|
||||
|
||||
const eyebrowEl = document.getElementById("eyebrow");
|
||||
const titleEl = document.getElementById("title");
|
||||
const bodyEl = document.getElementById("body");
|
||||
const progressEl = document.getElementById("progress-fill");
|
||||
|
||||
const TOTAL_DURATION = cumulative + TAIL_PAD;
|
||||
const driver = { t: 0 };
|
||||
let lastTitle = "";
|
||||
|
||||
tl.to(
|
||||
driver,
|
||||
{
|
||||
t: TOTAL_DURATION,
|
||||
duration: TOTAL_DURATION,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
const entry = entryAt(driver.t);
|
||||
// Only swap content on transitions (avoid per-frame DOM thrash)
|
||||
if (entry.title !== lastTitle) {
|
||||
eyebrowEl.textContent = entry.eyebrow;
|
||||
titleEl.textContent = entry.title;
|
||||
bodyEl.textContent = entry.body;
|
||||
lastTitle = entry.title;
|
||||
}
|
||||
// Progress bar fills 0% → 100% as composition advances
|
||||
progressEl.style.width = `${(driver.t / TOTAL_DURATION) * 100}%`;
|
||||
},
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
window.__timelines["seq-scene"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
### Crossfade between items (not hard cut)
|
||||
|
||||
Add `overlap` to the find function — return BOTH the previous and next entry during the overlap window, render with crossfade opacity:
|
||||
|
||||
```js
|
||||
function activeEntries(time, overlap = 0.3) {
|
||||
const result = [];
|
||||
TIMELINE.forEach((e) => {
|
||||
if (time >= e.start - overlap && time <= e.end + overlap) result.push(e);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
Then render the two adjacent entries with computed opacities based on distance from boundary.
|
||||
|
||||
### Per-item motion variation
|
||||
|
||||
Each entry has its own motion style. Map `entry.style` to one of the existing rules: chapter 1 uses [3d-text-depth-layers](3d-text-depth-layers.md), chapter 2 uses [hacker-flip-3d](hacker-flip-3d.md), chapter 3 uses [counting-dynamic-scale](counting-dynamic-scale.md). The sequencer just orchestrates timing; per-entry rendering uses the appropriate rule.
|
||||
|
||||
### Auto-extend composition duration
|
||||
|
||||
If you don't know upfront how long the sequence will be (dynamic content count), bind `data-duration` to the computed `TOTAL_DURATION`. Do this in script BEFORE the timeline registers:
|
||||
|
||||
```js
|
||||
document
|
||||
.querySelector("[data-composition-id]")
|
||||
.setAttribute("data-duration", String(Math.ceil(TOTAL_DURATION)));
|
||||
```
|
||||
|
||||
(Caveat: HF reads `data-duration` at composition load; setting after init may not take effect — author the duration manually based on a rough TOTAL calc.)
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Pre-compute timeline once, not per-frame** — building absolute start/end at script init means onUpdate is O(log n) reverse-search, not O(n²).
|
||||
- **Per-item duration formula: `BASE_DURATION + body.length × SEC_PER_CHAR + hold`** — longer text needs more reading time. The formula is the load-bearing teaching of this rule; ranges for each const are in How to Choose Values.
|
||||
- **Reserve `min-height` on body element** — content height varies per item; without reservation, layout jumps and downstream elements (progress bar, brand) jitter.
|
||||
- **DOM update on transition, not every frame** — track `lastTitle` (or whatever key) and only call `textContent =` when it changes. Per-frame textContent assignment causes flicker in HF render.
|
||||
- **Optional progress indicator** — a thin bar at the bottom showing 0-100% completes the "this is a sequence" framing.
|
||||
- **Climax dwell longer than mid-sequence dwell** — the outro's `hold` (HOLD_FINAL) should exceed the in-sequence `hold` (HOLD_MID) so the final brand/CTA lands.
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
- **BASE_DURATION** — minimum visible time of an entry regardless of content length
|
||||
- Range: 0.6-1.5 s
|
||||
- Effects: low end snaps through short entries too fast for the eye; high end stalls on short titles
|
||||
- Constraints: ensures even one-word entries have time to read
|
||||
- Reference: see `../../examples/messaging-multi-phrase.html` (and any blueprint that uses this rule)
|
||||
|
||||
- **SEC_PER_CHAR** — extra time added per body character
|
||||
- Range: 0.03-0.06 s/char (≈ 17-33 chars/sec read pace for video)
|
||||
- Effects: low end feels rushed for paragraph-style bodies; high end feels slow when bodies are short
|
||||
- Constraints: should be uniform across the sequence so the pace reads as one engine; for languages with wider characters, lean to the high end
|
||||
- Reference: see `../../examples/messaging-multi-phrase.html` (and any blueprint that uses this rule)
|
||||
|
||||
- **HOLD_MID** — dwell after the typing of a non-final entry completes
|
||||
- Range: 0.5-1.0 s
|
||||
- Effects: low end feels rushed; high end feels lazy
|
||||
- Constraints: `HOLD_MID < HOLD_FINAL`
|
||||
- Reference: see `../../examples/messaging-multi-phrase.html` (and any blueprint that uses this rule)
|
||||
|
||||
- **HOLD_FINAL** — dwell on the last entry (outro / climax)
|
||||
- Range: 1.0-2.0 s
|
||||
- Effects: low end truncates the closing beat; high end overstays
|
||||
- Constraints: must exceed HOLD_MID by a clear margin so the close reads as a beat, not another mid-sequence pause
|
||||
- Reference: see `../../examples/messaging-multi-phrase.html` (and any blueprint that uses this rule)
|
||||
|
||||
- **SPEED_FACTOR** — per-entry pacing multiplier
|
||||
- Range: 0.5-2.0 (default 1.0)
|
||||
- Effects: <1 stretches an entry's body-driven duration (good for high-density passages); >1 compresses it
|
||||
- Constraints: discrete choice — use 1.0 unless one entry needs special pacing; if every entry uses the same factor, fold it into SEC_PER_CHAR instead
|
||||
- Reference: see `../../examples/messaging-multi-phrase.html` (and any blueprint that uses this rule)
|
||||
|
||||
- **TAIL_PAD** — seconds added to `TOTAL_DURATION` after the last entry's `end`
|
||||
- Range: 0.0-1.0 s
|
||||
- Effects: 0 ends the driver exactly at the last `hold` completion; >0 leaves a quiet beat (useful before a transition to the next composition)
|
||||
- Constraints: if downstream is another composition, prefer 0 and handle the breath at the composition seam
|
||||
- Reference: see `../../examples/messaging-multi-phrase.html` (and any blueprint that uses this rule)
|
||||
|
||||
- **CONTENT length (N)** — number of entries in the sequence
|
||||
- Range: 3-6 entries
|
||||
- Effects: <3 isn't a sequence (use a static scene); >6 drags
|
||||
- Constraints: each entry's `title` must fit one line at the chosen `.title` fontSize; bodies should fit within `min-height` after wrapping
|
||||
- Reference: see `../../examples/messaging-multi-phrase.html` (and any blueprint that uses this rule)
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Timeline must be paused**: `gsap.timeline({ paused: true })`
|
||||
- **Registry key = `data-composition-id`**
|
||||
- **Pre-compute the TIMELINE array** — don't recompute in onUpdate
|
||||
- **`min-height` on body** for layout stability
|
||||
- **DOM swap only on entry transition** — use lastTitle/lastKey guard
|
||||
- **Sequential only** — for parallel tracks, use a different reduction (this rule is sequential)
|
||||
|
||||
## Combinations
|
||||
|
||||
- [discrete-text-sequence.md](discrete-text-sequence.md) — per-entry typewriter on the body
|
||||
- [context-sensitive-cursor.md](context-sensitive-cursor.md) — cursor color per chapter segment
|
||||
- [vertical-spring-ticker.md](vertical-spring-ticker.md) — animated word transitions between items (instead of hard cut)
|
||||
- [scale-swap-transition.md](scale-swap-transition.md) — visual morph between entries
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — single driver, reverse-search dispatch
|
||||
- `/hyperframes-core` — composition wiring
|
||||
- `/hyperframes-cli` — `hyperframes lint`
|
||||
@@ -0,0 +1,299 @@
|
||||
# GSAP Effects for HyperFrames
|
||||
|
||||
Drop-in animation patterns. Each effect is self-contained (HTML + CSS + JS) and follows the HyperFrames seek-driven contract — deterministic, no randomness, timeline registered on `window.__timelines`.
|
||||
|
||||
## Index
|
||||
|
||||
- [Typewriter](#typewriter) — character-by-character text reveal with optional cursor / backspace / word rotation
|
||||
- [Audio Visualizer](#audio-visualizer) — pre-extract audio data, drive Canvas/DOM rendering from the timeline
|
||||
|
||||
---
|
||||
|
||||
## Typewriter
|
||||
|
||||
Reveal text character by character using GSAP's TextPlugin.
|
||||
|
||||
### Required Plugin
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/TextPlugin.min.js"></script>
|
||||
<script>
|
||||
gsap.registerPlugin(TextPlugin);
|
||||
</script>
|
||||
```
|
||||
|
||||
### Basic Typewriter
|
||||
|
||||
```js
|
||||
const text = "Hello, world!";
|
||||
const cps = 10; // chars per second: 3-5 dramatic, 8-12 conversational, 15-20 energetic
|
||||
tl.to(
|
||||
"#typed-text",
|
||||
{ text: { value: text }, duration: text.length / cps, ease: "none" },
|
||||
startTime,
|
||||
);
|
||||
```
|
||||
|
||||
### With Blinking Cursor
|
||||
|
||||
Three rules:
|
||||
|
||||
1. **One cursor visible at a time** — hide previous before showing next.
|
||||
2. **Cursor must blink when idle** — after typing, during pauses.
|
||||
3. **No gap between text and cursor** — elements must be flush in HTML.
|
||||
|
||||
```html
|
||||
<span id="typed-text"></span><span id="cursor" class="cursor-blink">|</span>
|
||||
```
|
||||
|
||||
```css
|
||||
@keyframes blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.cursor-blink {
|
||||
animation: blink 0.8s step-end infinite;
|
||||
}
|
||||
.cursor-solid {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
.cursor-hide {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
}
|
||||
```
|
||||
|
||||
Pattern: blink → solid (typing starts) → type → solid → blink (typing done).
|
||||
|
||||
```js
|
||||
tl.call(() => cursor.classList.replace("cursor-blink", "cursor-solid"), [], startTime);
|
||||
tl.to("#typed-text", { text: { value: text }, duration: dur, ease: "none" }, startTime);
|
||||
tl.call(() => cursor.classList.replace("cursor-solid", "cursor-blink"), [], startTime + dur);
|
||||
```
|
||||
|
||||
### Backspacing
|
||||
|
||||
TextPlugin removes from front — wrong for backspace. Use manual substring removal:
|
||||
|
||||
```js
|
||||
function backspace(tl, selector, word, startTime, cps) {
|
||||
const el = document.querySelector(selector);
|
||||
const interval = 1 / cps;
|
||||
for (let i = word.length - 1; i >= 0; i--) {
|
||||
tl.call(
|
||||
() => {
|
||||
el.textContent = word.slice(0, i);
|
||||
},
|
||||
[],
|
||||
startTime + (word.length - i) * interval,
|
||||
);
|
||||
}
|
||||
return word.length * interval;
|
||||
}
|
||||
```
|
||||
|
||||
### Spacing With Static Text
|
||||
|
||||
When a typewriter word sits next to static text, use `margin-left` on a wrapper span. Don't use flex `gap` (it spaces the cursor from the text) and don't put a trailing space in the static text (it collapses when the dynamic span is empty).
|
||||
|
||||
```html
|
||||
<div style="display:flex; align-items:baseline;">
|
||||
<span style="font-size:40px; color:#555;">Ship something</span>
|
||||
<span style="margin-left:14px;"><span id="word"></span><span id="cursor">|</span></span>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Word Rotation
|
||||
|
||||
Type → hold → backspace → next word. Cursor blinks during every idle moment (holds, after backspace).
|
||||
|
||||
```js
|
||||
let offset = 0;
|
||||
words.forEach((word, i) => {
|
||||
const typeDur = word.length / 10;
|
||||
tl.call(() => cursor.classList.replace("cursor-blink", "cursor-solid"), [], offset);
|
||||
tl.to("#typed-text", { text: { value: word }, duration: typeDur, ease: "none" }, offset);
|
||||
tl.call(() => cursor.classList.replace("cursor-solid", "cursor-blink"), [], offset + typeDur);
|
||||
offset += typeDur + 1.5; // hold
|
||||
|
||||
if (i < words.length - 1) {
|
||||
tl.call(() => cursor.classList.replace("cursor-blink", "cursor-solid"), [], offset);
|
||||
const clearDur = backspace(tl, "#typed-text", word, offset, 20);
|
||||
tl.call(() => cursor.classList.replace("cursor-solid", "cursor-blink"), [], offset + clearDur);
|
||||
offset += clearDur + 0.3;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Appending Words
|
||||
|
||||
Build a sentence word-by-word into the same element:
|
||||
|
||||
```js
|
||||
let accumulated = "";
|
||||
let offset = 0;
|
||||
words.forEach((word) => {
|
||||
const target = accumulated + (accumulated ? " " : "") + word;
|
||||
const newChars = target.length - accumulated.length;
|
||||
tl.to("#typed-text", { text: { value: target }, duration: newChars / 10, ease: "none" }, offset);
|
||||
accumulated = target;
|
||||
offset += newChars / 10 + 0.3;
|
||||
});
|
||||
```
|
||||
|
||||
### Multi-Line Cursor Handoff
|
||||
|
||||
Handing off between typewriter lines: hide previous → blink new → pause → solid when typing. Never go `hidden → solid` (skips the idle blink).
|
||||
|
||||
```js
|
||||
tl.call(
|
||||
() => {
|
||||
prevCursor.classList.replace("cursor-blink", "cursor-hide");
|
||||
nextCursor.classList.replace("cursor-hide", "cursor-blink");
|
||||
},
|
||||
[],
|
||||
handoffTime,
|
||||
);
|
||||
|
||||
const typeStart = handoffTime + 0.5; // brief blink pause
|
||||
tl.call(() => nextCursor.classList.replace("cursor-blink", "cursor-solid"), [], typeStart);
|
||||
tl.to("#next-text", { text: { value: text }, duration: dur, ease: "none" }, typeStart);
|
||||
tl.call(() => nextCursor.classList.replace("cursor-solid", "cursor-blink"), [], typeStart + dur);
|
||||
```
|
||||
|
||||
### Timing Guide
|
||||
|
||||
| CPS | Feel | Good for |
|
||||
| ----- | ---------------- | -------------------------- |
|
||||
| 3-5 | Slow, deliberate | Dramatic reveals, suspense |
|
||||
| 8-12 | Natural typing | Dialogue, narration |
|
||||
| 15-20 | Fast, energetic | Tech demos, code |
|
||||
| 30+ | Near-instant | Filling long blocks |
|
||||
|
||||
---
|
||||
|
||||
## Audio Visualizer
|
||||
|
||||
Pre-extract audio data, drive Canvas / DOM rendering from a single `tl.call(...)` per frame. **Do not** use the Web Audio API at render time — there's no playback during seek.
|
||||
|
||||
### Extract Audio Data
|
||||
|
||||
Use the bundled extractor (requires `ffmpeg` and Python `numpy`):
|
||||
|
||||
```bash
|
||||
python skills/hyperframes-creative/scripts/extract-audio-data.py audio.mp3 -o audio-data.json
|
||||
python skills/hyperframes-creative/scripts/extract-audio-data.py video.mp4 --fps 30 --bands 16 -o audio-data.json
|
||||
```
|
||||
|
||||
### Data Format
|
||||
|
||||
```json
|
||||
{
|
||||
"fps": 30,
|
||||
"totalFrames": 5415,
|
||||
"frames": [{ "time": 0.0, "rms": 0.42, "bands": [0.8, 0.6, 0.3] }]
|
||||
}
|
||||
```
|
||||
|
||||
- **`rms`** (0-1) — overall loudness, normalized across the track.
|
||||
- **`bands[]`** (0-1) — frequency magnitudes. Index 0 = bass, higher index = treble. Each band normalized independently.
|
||||
|
||||
### Loading the Data (Synchronously)
|
||||
|
||||
```js
|
||||
// Option A — inline (small files, under ~500 KB)
|
||||
var AUDIO_DATA = {
|
||||
/* paste audio-data.json contents */
|
||||
};
|
||||
|
||||
// Option B — sync XHR (large files; must be synchronous for deterministic timeline construction)
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open("GET", "audio-data.json", false);
|
||||
xhr.send();
|
||||
var AUDIO_DATA = JSON.parse(xhr.responseText);
|
||||
```
|
||||
|
||||
**Do NOT use async `fetch()`.** HyperFrames reads `window.__timelines` synchronously after page load — building the timeline inside `.then()` means the timeline isn't ready when capture starts.
|
||||
|
||||
### Driving the Timeline
|
||||
|
||||
**Canvas 2D** — most common (bars, waveforms, circles, gradients):
|
||||
|
||||
```js
|
||||
const canvas = document.getElementById("viz");
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
for (let f = 0; f < AUDIO_DATA.totalFrames; f++) {
|
||||
tl.call(
|
||||
() => {
|
||||
const frame = AUDIO_DATA.frames[f];
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
// draw using frame.rms and frame.bands
|
||||
},
|
||||
[],
|
||||
f / AUDIO_DATA.fps,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**WebGL / Three.js** — HyperFrames patches `THREE.Clock` for deterministic time. Update uniforms from audio data each frame.
|
||||
|
||||
**DOM elements** — fine for fewer than ~20 elements, slower than Canvas for many.
|
||||
|
||||
### Smoothing
|
||||
|
||||
```js
|
||||
let prev = null;
|
||||
const smoothing = 0.25; // 0.1-0.2 snappy, 0.3-0.5 flowing
|
||||
function smooth(f) {
|
||||
const raw = AUDIO_DATA.frames[f];
|
||||
if (!prev) {
|
||||
prev = { rms: raw.rms, bands: [...raw.bands] };
|
||||
return prev;
|
||||
}
|
||||
prev = {
|
||||
rms: prev.rms * smoothing + raw.rms * (1 - smoothing),
|
||||
bands: raw.bands.map((b, i) => prev.bands[i] * smoothing + b * (1 - smoothing)),
|
||||
};
|
||||
return prev;
|
||||
}
|
||||
```
|
||||
|
||||
### Spatial Mapping
|
||||
|
||||
- **Horizontal**: bass left, treble right (iterate bands left-to-right)
|
||||
- **Vertical**: bass bottom, treble top
|
||||
- **Circular**: bass at 12 o'clock, wrap clockwise; mirror for a full circle
|
||||
|
||||
### Motion Principles
|
||||
|
||||
- **Bass drives big moves** — scale, glow, position shifts.
|
||||
- **Treble drives detail** — shimmer, flicker, edge effects.
|
||||
- **RMS drives globals** — background brightness, overall energy.
|
||||
- Pick 2-3 properties to animate. More looks noisy.
|
||||
- Keep minimums above zero — quiet sections still need life.
|
||||
|
||||
### Band Count
|
||||
|
||||
| Bands | Detail | Good for |
|
||||
| ----- | --------- | -------------------------- |
|
||||
| 4 | Low | Background glow, pulsing |
|
||||
| 8 | Medium | Bar charts, basic spectrum |
|
||||
| 16 | High | Detailed EQ (default) |
|
||||
| 32 | Very high | Dense radial layouts |
|
||||
|
||||
### Layering
|
||||
|
||||
Layer multiple canvases with CSS `z-index` for depth — a background layer driven by bass/rms and a foreground layer driven by individual bands creates depth without per-element complexity.
|
||||
|
||||
```html
|
||||
<canvas id="bg-layer" style="position:absolute;top:0;left:0;z-index:1;"></canvas>
|
||||
<canvas id="main-layer" style="position:absolute;top:0;left:0;z-index:2;"></canvas>
|
||||
```
|
||||
@@ -0,0 +1,223 @@
|
||||
---
|
||||
name: hacker-flip-3d
|
||||
description: Character-level 3D rotation with random glyph substitution for a decryption reveal effect.
|
||||
metadata:
|
||||
tags: text, 3d, reveal, decode, hacker, randomization, perspective
|
||||
---
|
||||
|
||||
# Hacker Flip 3D Reveal
|
||||
|
||||
Characters flip down from 90° in 3D while cycling through random glyphs, then settle on the target character. Creates a "decryption" or airport flap-display reveal.
|
||||
|
||||
## How It Works
|
||||
|
||||
Each character gets its own per-char tween from `rotateX: 90deg` (hidden) to `rotateX: 0deg` (revealed), staggered across the word. During the flip:
|
||||
|
||||
1. **Phase A (0 → ~`REVEAL_THRESHOLD` progress)**: character displays a randomly-substituted glyph that flickers (changes every `FLICKER_RATE` frames)
|
||||
2. **Phase B (`REVEAL_THRESHOLD` → 1.0 progress)**: character displays the REAL target character, settling into its final upright position
|
||||
|
||||
The `REVEAL_THRESHOLD` separates "scrambled" from "revealed" — by the time the flip is mostly done, viewer sees the correct letter clicking into place.
|
||||
|
||||
## HTML
|
||||
|
||||
```html
|
||||
<div
|
||||
class="scene"
|
||||
id="hacker-flip-scene"
|
||||
data-composition-id="hacker-flip-scene"
|
||||
data-start="0"
|
||||
data-duration="3"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div class="hacker-text-wrap" id="hacker-text" data-target="{phrase}">
|
||||
<!-- Per-char spans get injected by setup script below.
|
||||
Ghost placeholder (data-ghost) is rendered identically to reserve width. -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
`{phrase}` is the target word the flip resolves to (typically a brand or short label).
|
||||
|
||||
## CSS
|
||||
|
||||
```css
|
||||
.scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: {bgColor};
|
||||
perspective: 1500px; /* REQUIRED — without this rotateX renders flat */
|
||||
}
|
||||
|
||||
.hacker-text-wrap {
|
||||
font-family: {monoFont}; /* monospace recommended so flicker glyphs hold width */
|
||||
font-weight: 900;
|
||||
font-size: HACKER_FONT_SIZE;
|
||||
color: {textColor};
|
||||
letter-spacing: 4px;
|
||||
display: flex;
|
||||
/* Ghost / live chars are absolutely stacked; container reserves layout width */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hacker-char {
|
||||
display: inline-block;
|
||||
/* Hinge at the bottom edge — flap-display look */
|
||||
transform-origin: bottom;
|
||||
transform-style: preserve-3d;
|
||||
/* Will-change improves render perf */
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* Ghost placeholder is hidden but reserves width for variable-glyph fonts.
|
||||
Without this, narrow target glyphs collapse width when displayed and
|
||||
characters shift horizontally during flicker. */
|
||||
.hacker-ghost {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline + Random Glyph Logic
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
|
||||
const wrap = document.getElementById("hacker-text");
|
||||
const targetWord = wrap.dataset.target;
|
||||
const GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%&*";
|
||||
|
||||
// Build live chars + ghost placeholders (ghost keeps layout width stable)
|
||||
wrap.innerHTML = "";
|
||||
const ghostRow = document.createElement("div");
|
||||
ghostRow.className = "hacker-ghost";
|
||||
ghostRow.style.display = "inline-flex";
|
||||
ghostRow.style.position = "absolute";
|
||||
ghostRow.style.left = "0";
|
||||
ghostRow.style.top = "0";
|
||||
ghostRow.textContent = targetWord;
|
||||
wrap.appendChild(ghostRow);
|
||||
|
||||
const charEls = [];
|
||||
const liveRow = document.createElement("div");
|
||||
liveRow.style.display = "inline-flex";
|
||||
liveRow.style.position = "relative";
|
||||
for (const ch of targetWord) {
|
||||
const span = document.createElement("span");
|
||||
span.className = "hacker-char";
|
||||
span.textContent = ch === " " ? " " : ch;
|
||||
span.dataset.target = ch;
|
||||
liveRow.appendChild(span);
|
||||
charEls.push(span);
|
||||
}
|
||||
wrap.appendChild(liveRow);
|
||||
|
||||
// Deterministic "random" — seeded by char index + frame group so the same
|
||||
// frame always yields the same glyph (HF seek determinism).
|
||||
function pseudoGlyph(seed) {
|
||||
const h = ((seed * 9301 + 49297) % 233280) / 233280;
|
||||
return GLYPHS[Math.floor(h * GLYPHS.length)];
|
||||
}
|
||||
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// Per-char flip — stagger across the word
|
||||
charEls.forEach((el, i) => {
|
||||
const state = { p: 0 };
|
||||
tl.to(
|
||||
state,
|
||||
{
|
||||
p: 1,
|
||||
duration: FLIP_DURATION,
|
||||
ease: "power3.out",
|
||||
onUpdate: () => {
|
||||
// Phase A: random glyph flickering. Phase B: real character.
|
||||
const progress = state.p;
|
||||
if (progress < REVEAL_THRESHOLD) {
|
||||
// Update glyph every FLICKER_RATE worth of progress
|
||||
const flickerSeed = i * 1000 + Math.floor(progress * 100);
|
||||
el.textContent = pseudoGlyph(flickerSeed);
|
||||
} else {
|
||||
el.textContent = el.dataset.target === " " ? " " : el.dataset.target;
|
||||
}
|
||||
// Flip rotateX from 90 (down) to 0 (upright)
|
||||
const rotateX = 90 - progress * 90;
|
||||
const opacity = Math.min(1, progress * 2);
|
||||
el.style.transform = `rotateX(${rotateX}deg)`;
|
||||
el.style.opacity = opacity;
|
||||
},
|
||||
},
|
||||
i * CHAR_STAGGER,
|
||||
);
|
||||
});
|
||||
|
||||
window.__timelines["hacker-flip-scene"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
- **HACKER_FONT_SIZE** — font-size of the flip text in px.
|
||||
- Range: 6-10% of viewport min-dimension; the flip text is the focal beat, scale accordingly
|
||||
- Constraints: ghost row must use the identical size so layout width stays stable mid-flicker
|
||||
- Reference: ../../examples/proof-logo-chain.html uses `163px` at 1920×1080
|
||||
- **FLIP_DURATION** — per-character flip tween duration.
|
||||
- Range: 0.4-1.0s; under 0.4s the random-glyph phase has no time to flicker, over 1.0s drags
|
||||
- Effects: shorter feels snappy and modern; longer feels mechanical / typewriter
|
||||
- Reference: ../../examples/proof-logo-chain.html uses `0.55s`
|
||||
- **CHAR_STAGGER** — delay between consecutive characters starting their flips, in seconds.
|
||||
- Range: 0.03-0.08s; too fast and chars overlap visually, too slow and the effect feels labored
|
||||
- Constraints: total decode time = `CHAR_STAGGER × (charCount − 1) + FLIP_DURATION`; ensure this fits the phase budget
|
||||
- Reference: ../../examples/proof-logo-chain.html uses `0.033s` (≈2 frames at 60fps)
|
||||
- **REVEAL_THRESHOLD** — progress at which a glyph swaps from random → real.
|
||||
- Range: 0.5-0.7; lower reveals too early (no decode tension), higher feels like a hard reveal at the end
|
||||
- Effects: this is a discrete tuning of when the eye locks onto the real letter
|
||||
- Reference: ../../examples/proof-logo-chain.html uses `0.6`
|
||||
- **FLICKER_RATE** — frames between glyph reshuffles during the random phase.
|
||||
- Range: 3-6; lower than 3 looks like noise, higher than 6 looks like discrete typing instead of flicker
|
||||
- Constraints: must be ≥ ~3 frames (see Critical Constraints)
|
||||
- Reference: ../../examples/proof-logo-chain.html uses an equivalent of `3` (one shuffle every 3 internal-clock frames)
|
||||
- **{bgColor} / {textColor}** — stage background and live-character color tokens.
|
||||
- **{monoFont}** — monospace family preferred so flicker glyphs don't change width per swap; if a proportional font is required, the ghost placeholder makes the cost recoverable.
|
||||
- **{phrase}** — the target word the flip resolves to. Length feeds the total decode duration via `CHAR_STAGGER`.
|
||||
|
||||
## Variations
|
||||
|
||||
- **Top-down hinge** — swap `transform-origin: bottom` to `top` for a falling-flap look.
|
||||
- **Center spin** — `transform-origin: center` reads as a barrel roll, not a flap.
|
||||
- **Number-only pool** — restrict `GLYPHS` to digits for a price / countdown decode.
|
||||
- **Two-pass decode** — chain two `FLIP_DURATION` tweens with different glyph pools (e.g. symbols → letters → real) for a longer reveal.
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Threshold at ~`REVEAL_THRESHOLD`** for swap from random → real glyph — close enough to settled that viewer's eye catches the right letter
|
||||
- **Hinge at `transform-origin: bottom`** for flap-display look (vs `top` for top-down, vs `center` for spin)
|
||||
- **Deterministic random** via seeded hash — HF runtime seeks frame-by-frame, so the same frame must show the same glyph (no `Math.random()`)
|
||||
- **Ghost placeholder** sits behind the live chars with identical content + same font, reserving width — without it, narrow glyphs shift the layout mid-flicker
|
||||
- **Stagger in the 0.04-0.08s range** per char — too fast and chars overlap visually, too slow and effect feels labored
|
||||
- **Center the flip dead-center via `display: grid; place-items: center;`** on the scene root — and DO NOT add decorative headers/footers (timestamp lines, "// AUTH" tags, small status dots). The flip text IS the focal beat; surrounding clutter dilutes it. If a secondary label is necessary, promote it to BIG typography in the same stacked layout (56-72px caps + tracking), not a tiny corner annotation.
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **`perspective` on scene root REQUIRED** — without parent perspective, `rotateX` looks like a 2D scale, not a 3D flip
|
||||
- **`transform-style: preserve-3d` on each char** — keeps 3D context intact when chars have their own transforms
|
||||
- **Timeline must be paused**: `gsap.timeline({ paused: true })`
|
||||
- **Registry key = `data-composition-id`**
|
||||
- **Deterministic randomness**: don't use `Math.random()`. Use a seed derived from char index + frame group so seek determinism holds
|
||||
- **`onUpdate` writes to DOM**: HF seeks every frame, so this runs many times — keep work O(1) per char per frame
|
||||
- **Flicker rate ≥ ~3 frames per glyph swap**: faster looks like noise, slower looks like discrete typing
|
||||
|
||||
## Combinations
|
||||
|
||||
- [card-morph-anchor.md](card-morph-anchor.md) — pair: hacker-flip reveals a phrase, then card morphs into the next shot
|
||||
- [counting-dynamic-scale.md](counting-dynamic-scale.md) — counterpart for numeric reveals (text vs number)
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — timeline + per-char stagger + `onUpdate`
|
||||
- `/hyperframes-core` — composition wiring
|
||||
- `/hyperframes-cli` — `hyperframes lint`
|
||||
@@ -0,0 +1,183 @@
|
||||
---
|
||||
name: kinetic-beat-slam
|
||||
description: Percussive kinetic typography — short phrases slam in on a steady beat with distinct per-phrase entrances, optional rhythm chrome (metronome ticks, beat bar), then a locked finale.
|
||||
metadata:
|
||||
tags: text, kinetic, typography, beat, rhythm, slam, percussive, punchy
|
||||
---
|
||||
|
||||
# Kinetic Beat Slam
|
||||
|
||||
Short phrases hit one at a time on a **steady beat**, each with a _different_ entrance, then stack into a locked finale. This is the recipe for "punchy / rhythmic" text-forward pieces (taglines, manifestos, hype intros). The difference between generic and rhythmic is (1) one shared **onset array** driving every element, (2) **distinct** entrances per phrase rather than one reused helper, and (3) optional **rhythm chrome** that visibly keeps the beat.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Define the beat once.** A single `BEATS = [t0, t1, t2, …]` array (seconds) is the rhythmic spine. Every phrase entrance, accent, and chrome tick reads its time from this array — so the whole piece locks to one pulse instead of drifting hand-tuned offsets.
|
||||
2. **Vary the entrances.** Phrase 1 slams (scale + blur), phrase 2 snaps from the side, phrase 3 rises and rotates. Same _energy_, different _form_ — reusing one `punchIn()` for all three reads as flat.
|
||||
3. **Land a finale.** All phrases lock into a left-aligned or centered stack; an accent underline sweeps in; optionally a continuous low-amplitude pulse holds the last beat.
|
||||
|
||||
## Beat & Easing
|
||||
|
||||
Pick the entrance easing by attack character (the choice is discrete):
|
||||
|
||||
| GSAP ease | Attack feel |
|
||||
| ------------- | ------------------------------------------- |
|
||||
| `power4.out` | Hard slam, fast settle ⭐ default for a hit |
|
||||
| `expo.out` | Hardest snap (side-snaps, whip-ins) |
|
||||
| `back.out(2)` | Overshoot pop — accents, not body words |
|
||||
| `circ.out` | Heavy rise with momentum |
|
||||
|
||||
Use **at least 3 distinct easings** across the piece (entrances are its "tone of voice"). Keep durations short — 0.35–0.6s on the hit, ≤0.25s on the exit — so the beat stays percussive.
|
||||
|
||||
## HTML
|
||||
|
||||
```html
|
||||
<section class="clip" data-start="0" data-duration="15" data-track-index="1">
|
||||
<div class="kbs-stage">
|
||||
<div class="kbs-line" id="p1"><span class="verb">Notice</span> more.</div>
|
||||
<div class="kbs-line" id="p2"><span class="verb">Decide</span> faster.</div>
|
||||
<div class="kbs-line" id="p3"><span class="verb">Act</span> now.</div>
|
||||
</div>
|
||||
<!-- optional rhythm chrome -->
|
||||
<div class="kbs-metronome" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i></div>
|
||||
</section>
|
||||
```
|
||||
|
||||
## CSS
|
||||
|
||||
```css
|
||||
.kbs-stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 120px 160px; /* title-safe margin */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.kbs-line {
|
||||
font-family: "Archivo Black", "League Gothic", sans-serif; /* embedded display face */
|
||||
font-size: 150px;
|
||||
line-height: 0.96;
|
||||
letter-spacing: -0.03em;
|
||||
color: #f5f5f5;
|
||||
will-change: transform, filter, opacity;
|
||||
}
|
||||
.kbs-line .verb {
|
||||
color: #ff5b2e;
|
||||
} /* one accent hue */
|
||||
.kbs-metronome {
|
||||
position: absolute;
|
||||
bottom: 64px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
}
|
||||
.kbs-metronome i {
|
||||
width: 6px;
|
||||
height: 28px;
|
||||
background: #ff5b2e;
|
||||
opacity: 0.25;
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// ONE tempo grid drives everything — phrases AND the metronome read it (no scattered offsets).
|
||||
const PULSE = 0.4; // seconds per sub-beat (the grid)
|
||||
const BEATS = [PULSE * 1, PULSE * 5, PULSE * 9]; // phrase onsets, on the grid
|
||||
|
||||
// Distinct entrances per phrase (NOT one reused helper).
|
||||
tl.fromTo(
|
||||
"#p1",
|
||||
{ scale: 1.5, filter: "blur(16px)", opacity: 0 },
|
||||
{ scale: 1, filter: "blur(0px)", opacity: 1, duration: 0.5, ease: "power4.out" },
|
||||
BEATS[0],
|
||||
);
|
||||
tl.fromTo(
|
||||
"#p2",
|
||||
{ x: -320, opacity: 0 },
|
||||
{ x: 0, opacity: 1, duration: 0.45, ease: "expo.out" },
|
||||
BEATS[1],
|
||||
);
|
||||
tl.fromTo(
|
||||
"#p3",
|
||||
{ y: 90, rotation: 6, opacity: 0 },
|
||||
{ y: 0, rotation: 0, opacity: 1, duration: 0.55, ease: "circ.out" },
|
||||
BEATS[2],
|
||||
);
|
||||
|
||||
// Rhythm chrome: each metronome tick flashes on the SAME grid (PULSE), not a magic offset.
|
||||
const ticks = gsap.utils.toArray(".kbs-metronome i");
|
||||
ticks.forEach((tick, i) => {
|
||||
tl.to(
|
||||
tick,
|
||||
{ opacity: 1, duration: 0.08, yoyo: true, repeat: 1, ease: "none" },
|
||||
PULSE * (i + 1),
|
||||
);
|
||||
});
|
||||
|
||||
// Finale hold: a low-amplitude breath on the locked stack.
|
||||
// floor (not ceil) so the repeat never overshoots data-duration; max(0,…) so a short hold
|
||||
// never yields a negative repeat (GSAP treats negative repeat as -1 = infinite = non-deterministic).
|
||||
const holdStart = BEATS[2] + 0.7,
|
||||
cycle = 1.6,
|
||||
holdDur = 15 - holdStart;
|
||||
tl.to(
|
||||
".kbs-stage",
|
||||
{
|
||||
scale: 1.01,
|
||||
duration: cycle / 2,
|
||||
ease: "sine.inOut",
|
||||
yoyo: true,
|
||||
repeat: Math.max(0, Math.floor(holdDur / cycle) - 1),
|
||||
},
|
||||
holdStart,
|
||||
);
|
||||
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
- **BEATS spacing** — 1.2–1.8s between hits reads as a confident beat; <0.8s feels frantic, >2.5s loses the pulse. Keep spacing even (it's a _beat_).
|
||||
- **Entrance duration** — 0.35–0.6s. The hit must resolve before the next beat.
|
||||
- **Distinct entrances** — assign a different transform axis per phrase (scale / x / y+rotate). Reuse the _ease family_, vary the _motion_.
|
||||
- **Accent hue** — exactly one (the verbs). The rest is mono white/near-black.
|
||||
- **Rhythm chrome** — optional but high-impact for "rhythmic": a 5-tick metronome, a center beat bar, or a `// label` monospace tag pulsing on-beat. Mark any decorative that must survive a shader transition per `../../transitions/overview.md` rules.
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **One beat array, not scattered offsets** — every element times off `BEATS[]`. This is the single biggest lever for "rhythmic."
|
||||
- **Different entrance per phrase** — a reused `punchIn()` for all lines is the flat-but-competent tell.
|
||||
- **Short attacks** — percussive means fast in, brief, decisive. Long fades kill the beat.
|
||||
- **One accent hue, heavy weight** — embedded display faces (Archivo Black, League Gothic, Oswald) at 150px+; see `hyperframes-creative/references/typography.md`.
|
||||
- **Finale earns the hold** — stack + underline sweep + optional breath; don't just leave the last phrase sitting.
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Timeline paused**: `gsap.timeline({ paused: true })`. Never `tl.play()`.
|
||||
- **No infinite repeats** on the hold/chrome — use `repeat: Math.max(0, Math.floor(dur / cycle) - 1)` (no `repeat: -1`). Use **`Math.floor`, not `Math.ceil`** — `ceil` overshoots `data-duration` and trips the `gsap_repeat_ceil_overshoot` lint rule; the `Math.max(0, …)` guards against a negative repeat (which GSAP reads as `-1` = infinite = non-deterministic) when the hold is shorter than two cycles.
|
||||
- **No banned exit animations** between scenes — if this is one of several scenes, the _transition_ is the exit (see `../../transitions/overview.md`); only a final scene may fade out.
|
||||
- **Display font must be embedded** or it silently falls back at render (Anton/Bebas-as-literal are NOT embedded — `Bebas Neue` aliases to League Gothic; verify in `typography.md`).
|
||||
- **Registry key = `data-composition-id`** on the root.
|
||||
|
||||
## Combinations
|
||||
|
||||
- [3d-text-depth-layers.md](3d-text-depth-layers.md) — extruded depth on the slammed words
|
||||
- [css-marker-patterns.md](css-marker-patterns.md) — underline sweep / circle on the finale
|
||||
- [sine-wave-loop.md](sine-wave-loop.md) — the finale breath/pulse
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — timeline + easing vocabulary (`../../adapters/gsap-easing-and-stagger.md`)
|
||||
- `/hyperframes-creative` — `references/video-composition.md` (foreground rhythm chrome), `references/typography.md` (embedded display fonts)
|
||||
- `/hyperframes-core` — composition wiring, determinism (finite repeats)
|
||||
@@ -0,0 +1,328 @@
|
||||
---
|
||||
name: motion-blur-streak
|
||||
description: Fake directional velocity blur on a fast entrance or camera push-through — blur peaks at max speed and resolves to 0 at the settle, so the element streaks in then snaps sharp. Two paths — SVG feGaussianBlur on the motion axis, or an echo/ghost trail that collapses into the lead.
|
||||
metadata:
|
||||
tags: motion-blur, velocity, streak, entrance, fly-in, ghost, echo, svg-filter, kinetic, camera, snap
|
||||
---
|
||||
|
||||
# Motion-Blur Streak
|
||||
|
||||
Real per-frame motion blur isn't available to a seeked renderer (it integrates over shutter time, which a paused timeline has no concept of), so this rule **fakes** it for a fast element fly-in or a hard camera push-through. The blur **peaks at maximum velocity and resolves to 0 at the settle** — the element reads as streaking in then snapping sharp on arrival. The whole point is the _coupling_: the blur envelope rides the same ease and window as the position tween, so peak-blur lands exactly on peak-speed, and the element is razor-sharp the instant it stops.
|
||||
|
||||
Two implementation paths, both finite, deterministic, and seek-safe:
|
||||
|
||||
- **(A) Directional SVG blur** — an inline `<filter>` with `<feGaussianBlur stdDeviation="X 0">` (X on the axis of motion, 0 across it). GSAP tweens `X` from high → 0 through a proxy that calls `setAttribute` each frame. Cleanest, one element, true directional smear.
|
||||
- **(B) Echo / ghost trail** — 2–4 duplicate copies at decreasing opacity, offset backward along the motion vector, collapsing into the lead element as it settles. No filter cost; reads as a "speed-line" stutter trail. Better when you want the streak _colored_ or _stylized_ rather than a literal optical blur.
|
||||
|
||||
This is for **entrances and mid-shot moves only** — a fast arrival, a beat that zooms past camera, a card slamming into a grid slot, a logo punching through into a lockup. **Never an exit on a non-final frame** (a blurred element fleeing off-frame mid-composition reads as a glitch, and a hard exit between scenes is the transition's job, not a per-element blur).
|
||||
|
||||
## How It Works
|
||||
|
||||
A fast move has a velocity profile: it accelerates off the start, peaks, then decelerates into the settle. An `out` ease (`expo.out`, `power4.out`) front-loads that — velocity is highest right at the start and bleeds to zero at the end. The fake works by mapping a **blur (or echo) envelope onto that same curve**:
|
||||
|
||||
1. **Position tween** — the element travels from an off-frame / pushed-back start to its resting transform (`x`/`y` for a fly-in, `scale` for a push-through) on a fast `out` ease over `MOVE_DUR`.
|
||||
2. **Blur envelope** — in lockstep over the **same window and ease**, the smear goes from `PEAK_BLUR` → `0`. Because the ease front-loads velocity and the envelope shares it, max blur coincides with max speed, and blur hits exactly `0` as the element lands.
|
||||
3. **Settle is sharp** — by `MOVE_START + MOVE_DUR` the element is at its resting transform with blur `0` (path A) or all echoes collapsed onto the lead (path B). It then holds, fully crisp, for the climax dwell.
|
||||
|
||||
Path A tweens the filter's `stdDeviation` attribute (a non-DOM-style numeric attribute) via a **proxy object** — GSAP can't tween an SVG attribute directly, so you tween a plain `{ v: PEAK_BLUR }` and write it back with `setAttribute` in `onUpdate`. Path B places the ghosts at deterministic backward offsets (`i * ECHO_STEP_PX`) and fades/collapses them on the same envelope.
|
||||
|
||||
## HTML
|
||||
|
||||
### Path A — directional SVG blur (recommended default)
|
||||
|
||||
```html
|
||||
<div
|
||||
class="scene"
|
||||
id="streak-scene"
|
||||
data-composition-id="streak-scene"
|
||||
data-start="0"
|
||||
data-duration="DURATION"
|
||||
data-track-index="0"
|
||||
>
|
||||
<!-- Inline filter: blur on the X axis only (stdDeviation="X 0"); 0 on Y keeps it a clean horizontal smear. -->
|
||||
<svg width="0" height="0" aria-hidden="true" style="position: absolute">
|
||||
<defs>
|
||||
<filter id="streak" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur id="streak-blur" in="SourceGraphic" stdDeviation="0 0" />
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<div class="streak-stage">
|
||||
<div class="streak-el" id="streak-el">{phrase}</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Path B — echo / ghost trail
|
||||
|
||||
```html
|
||||
<div
|
||||
class="scene"
|
||||
id="streak-scene"
|
||||
data-composition-id="streak-scene"
|
||||
data-start="0"
|
||||
data-duration="DURATION"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div class="streak-stage">
|
||||
<!-- N-1 ghosts BEHIND the lead, then the lead on top. Ghosts are aria-hidden duplicates. -->
|
||||
<div class="streak-ghost" data-i="3" aria-hidden="true">{phrase}</div>
|
||||
<div class="streak-ghost" data-i="2" aria-hidden="true">{phrase}</div>
|
||||
<div class="streak-ghost" data-i="1" aria-hidden="true">{phrase}</div>
|
||||
<div class="streak-el" id="streak-el">{phrase}</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS
|
||||
|
||||
```css
|
||||
.scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: {sceneBg};
|
||||
font-family: {font};
|
||||
overflow: hidden; /* the smear/echo extends past the resting position before settling */
|
||||
}
|
||||
.streak-stage {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.streak-el {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
font-size: EL_FONT_SIZE;
|
||||
font-weight: 900;
|
||||
letter-spacing: EL_TRACKING;
|
||||
color: {textColor};
|
||||
/* Path A only — reference the directional filter. (Omit for Path B.) */
|
||||
filter: url(#streak);
|
||||
will-change: transform, filter;
|
||||
}
|
||||
|
||||
/* Path B ghosts — identical glyphs behind the lead, decreasing opacity */
|
||||
.streak-ghost {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 1;
|
||||
font-size: EL_FONT_SIZE;
|
||||
font-weight: 900;
|
||||
letter-spacing: EL_TRACKING;
|
||||
color: {textColor};
|
||||
opacity: 0;
|
||||
will-change: transform, opacity;
|
||||
pointer-events: none;
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline
|
||||
|
||||
### Path A — directional SVG blur
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// GSAP can't tween an SVG attribute directly — tween a proxy and write it back each frame.
|
||||
const blurNode = document.getElementById("streak-blur");
|
||||
const blurProxy = { v: PEAK_BLUR };
|
||||
const writeBlur = () => blurNode.setAttribute("stdDeviation", `${blurProxy.v} 0`); // X-axis only
|
||||
writeBlur(); // seed frame 0 so a seek to t=0 shows the streaked start, not a sharp pre-frame
|
||||
|
||||
// Position: travel from off-frame to rest on a fast `out` ease (velocity front-loaded).
|
||||
tl.fromTo(
|
||||
"#streak-el",
|
||||
{ x: ENTER_FROM_X, opacity: 0 },
|
||||
{ x: 0, opacity: 1, duration: MOVE_DUR, ease: MOVE_EASE },
|
||||
MOVE_START,
|
||||
);
|
||||
|
||||
// Blur envelope: SAME window + SAME ease so peak-blur == peak-speed, resolving to 0 at the settle.
|
||||
tl.to(blurProxy, { v: 0, duration: MOVE_DUR, ease: MOVE_EASE, onUpdate: writeBlur }, MOVE_START);
|
||||
|
||||
window.__timelines["streak-scene"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
### Path B — echo / ghost trail
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// Lead element: same fast `out` move as Path A.
|
||||
tl.fromTo(
|
||||
"#streak-el",
|
||||
{ x: ENTER_FROM_X, opacity: 0 },
|
||||
{ x: 0, opacity: 1, duration: MOVE_DUR, ease: MOVE_EASE },
|
||||
MOVE_START,
|
||||
);
|
||||
|
||||
// Ghosts: each starts FURTHER back along the motion vector (deterministic, by index),
|
||||
// dimmer, and collapses onto the lead — all on the SAME ease/window so they vanish at the settle.
|
||||
const ghosts = gsap.utils.toArray(".streak-ghost");
|
||||
ghosts.forEach((g) => {
|
||||
const i = Number(g.dataset.i); // 1..N-1, set in HTML (no Math.random)
|
||||
tl.fromTo(
|
||||
g,
|
||||
{ x: ENTER_FROM_X - i * ECHO_STEP_PX, opacity: GHOST_BASE_OPACITY / i },
|
||||
{ x: 0, opacity: 0, duration: MOVE_DUR, ease: MOVE_EASE },
|
||||
MOVE_START,
|
||||
);
|
||||
});
|
||||
|
||||
window.__timelines["streak-scene"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
### Vertical streak (rise / drop-in)
|
||||
|
||||
Swap the motion axis: use `y` instead of `x` for the position tween, `stdDeviation="0 Y"` for Path A (blur on Y, 0 on X), and `ENTER_FROM_Y` / vertical echo offsets for Path B. A phrase that streaks _up_ into place pairs with `kinetic-type-beats`' rise-rotate beat.
|
||||
|
||||
### Camera push-through (scale streak into a lockup)
|
||||
|
||||
Instead of translating, the element rushes the camera: `scale: SCALE_FROM → 1` on the fast `out` ease, with a **radial / zoom blur** feel approximated by a symmetric `stdDeviation="B B"` envelope (blur on both axes since the smear is depth-wise, not directional). This is the `logo-assemble-lockup` push-through — the wordmark punches forward out of soft focus and snaps crisp at the lock.
|
||||
|
||||
```js
|
||||
tl.fromTo(
|
||||
"#streak-el",
|
||||
{ scale: SCALE_FROM, opacity: 0 },
|
||||
{ scale: 1, opacity: 1, duration: MOVE_DUR, ease: MOVE_EASE },
|
||||
MOVE_START,
|
||||
);
|
||||
tl.to(
|
||||
blurProxy,
|
||||
{
|
||||
v: 0,
|
||||
duration: MOVE_DUR,
|
||||
ease: MOVE_EASE,
|
||||
onUpdate: () => blurNode.setAttribute("stdDeviation", `${blurProxy.v} ${blurProxy.v}`),
|
||||
},
|
||||
MOVE_START,
|
||||
);
|
||||
```
|
||||
|
||||
### Staggered grid streak-in (cards assemble)
|
||||
|
||||
For `grid-card-assemble`: each card streaks into its slot from its own backward offset, staggered. Drive every card off the same ease/window with a per-index delay; derive the entrance offset and start time from the card's index (no `Math.random`). Each card is sharp the instant it lands in its slot.
|
||||
|
||||
```js
|
||||
gsap.utils.toArray(".grid-card").forEach((card, i) => {
|
||||
const at = MOVE_START + i * CARD_STAGGER;
|
||||
tl.fromTo(
|
||||
card,
|
||||
{ x: ENTER_FROM_X, opacity: 0 },
|
||||
{ x: 0, opacity: 1, duration: MOVE_DUR, ease: MOVE_EASE },
|
||||
at,
|
||||
);
|
||||
// + a per-card blur proxy tween at the same `at` (Path A), or per-card ghosts (Path B)
|
||||
});
|
||||
```
|
||||
|
||||
### Hold-the-streak (whip emphasis on a single beat)
|
||||
|
||||
For a single kinetic phrase that "zooms past," keep the streak slightly visible a frame or two longer by easing the blur on a marginally _slower_ curve than the position (e.g. position `expo.out`, blur `power3.out`) — the element arrives, then the last wisp of smear resolves. Use sparingly; the default is locked envelopes.
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
### Motion
|
||||
|
||||
- **MOVE_EASE** — shared ease for position and blur/echo.
|
||||
- Range: `expo.out` (hardest snap), `power4.out` (hard slam, default), `power3.out` (firm but softer)
|
||||
- Effects: harder `out` → velocity more front-loaded → blur reads as a sharper streak that resolves later in the window
|
||||
- Constraints: must be an `out`-family ease (velocity front-loaded). An `inOut` or `in` ease puts peak speed mid/late and the blur-speed coupling breaks. **Position and blur must use the SAME ease** (except the deliberate Hold-the-streak variation).
|
||||
- **MOVE_DUR** — travel + blur-resolve duration.
|
||||
- Range: 0.25–0.6 s
|
||||
- Effects: shorter → more violent whip; longer → a glide, the streak loses punch
|
||||
- Constraints: a streak is _fast_ — over ~0.7 s it stops reading as velocity blur and looks like a focus pull
|
||||
- **MOVE_START** — timeline position of the entrance.
|
||||
- Constraints: leave **≥1 s of dwell** after `MOVE_START + MOVE_DUR` before the composition ends (climax dwell — a streak that lands at `t = DURATION − 0.2 s` reads as "flashed and gone")
|
||||
- **ENTER_FROM_X / ENTER_FROM_Y** — off-frame start offset along the motion axis.
|
||||
- Range: 40–120% of the element's own dimension on that axis (far enough to read as "came from off-frame")
|
||||
- Effects: larger → longer travel → the streak has more runway to read; too small and there's no sense of speed
|
||||
|
||||
### Path A — SVG blur
|
||||
|
||||
- **PEAK_BLUR** — `stdDeviation` at maximum velocity (start of the window).
|
||||
- Range: 8 (subtle) → 18 (default) → 30 (extreme whip)
|
||||
- Effects: higher → heavier smear at peak speed; too high erases the glyph entirely at the start frame
|
||||
- Constraints: ≤ ~30 — beyond that the element is unreadable for the first several frames and reads as "missing then appearing"; the filter region (`x/y/width/height` on `<filter>`) must be large enough (≥`-50% … 200%`) or the smear clips at the box edge
|
||||
- **SCALE_FROM** (push-through variation) — starting scale for a camera push.
|
||||
- Range: 1.3 (gentle push) → 2.5 (aggressive punch-through)
|
||||
|
||||
### Path B — echo trail
|
||||
|
||||
- **N (ghost count)** — number of ghosts behind the lead (set by how many `.streak-ghost` you author).
|
||||
- Range: 2–4
|
||||
- Effects: more ghosts → a longer, smoother smear; >4 reads as a stutter / strobe rather than a streak
|
||||
- **ECHO_STEP_PX** — backward offset per ghost along the motion vector.
|
||||
- Range: 12–40 px
|
||||
- Effects: larger → a more spread-out, visible trail; smaller → a tight blur-like cluster
|
||||
- Constraints: `(N) × ECHO_STEP_PX` should be ≲ `ENTER_FROM_X` so the furthest ghost still starts within the travel runway
|
||||
- **GHOST_BASE_OPACITY** — opacity of the nearest ghost (`i = 1`); falls off as `BASE / i`.
|
||||
- Range: 0.3 (faint) → 0.6 (pronounced)
|
||||
- Constraints: ≤ ~0.6 — opaque ghosts read as duplicate elements, not a trail
|
||||
|
||||
### Layout & type
|
||||
|
||||
- **EL_FONT_SIZE / EL_TRACKING** — the streaking element's type weight (when it's a phrase).
|
||||
- Constraints: heavy display weight (≥120 px at 1080p, ≥800 weight) so the smear has mass to streak; thin type smears into invisibility
|
||||
- **CARD_STAGGER** (grid variation) — delay between consecutive cards.
|
||||
- Range: 0.05–0.12 s — tight enough to read as one assembling wave, not separate arrivals
|
||||
|
||||
### Tokens
|
||||
|
||||
- **{sceneBg}** — background; a streak reads best against a solid / low-detail field (a busy bg fights the smear)
|
||||
- **{font}** — typographic stack (embedded display face if the streaking element is text — see typography reference)
|
||||
- **{textColor}** — element color; for Path B the ghosts inherit this, so a slightly desaturated trail can be had by tinting `.streak-ghost` separately
|
||||
- **{phrase}** — the word / glyph / wordmark that streaks in
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Blur peaks at peak speed, resolves to 0 at the settle** — this is the whole rule. Share the ease and window between the position tween and the blur/echo envelope so they're locked. A blur that lingers after the element stops, or peaks after it's already slow, reads as a focus pull, not velocity.
|
||||
- **`out`-family ease, always** — velocity must be front-loaded (fast off the start, decelerating in). `expo.out` / `power4.out` / `power3.out`. An `in` or `inOut` ease puts peak speed in the wrong place and the coupling falls apart.
|
||||
- **Directional blur on the motion axis** (Path A) — `stdDeviation="X 0"` for horizontal, `"0 Y"` for vertical, `"B B"` only for a depth/scale push. A symmetric blur on a sideways move looks like defocus, not speed.
|
||||
- **Tween a proxy, write the attribute** (Path A) — GSAP tweens the plain `{ v }` object; `onUpdate` calls `setAttribute("stdDeviation", …)`. You cannot tween the SVG attribute directly, and you must **seed it once at setup** so a seek to `t=0` shows the streaked start.
|
||||
- **Ghosts are deterministic, by index** (Path B) — offset `i * ECHO_STEP_PX`, opacity `BASE / i`. Never `Math.random` for the trail; index drives all per-ghost variation so every seek is identical.
|
||||
- **Entrances only, never a mid-composition exit** — a streak is an _arrival_. A blurred element leaving on a non-final frame reads as a glitch; scene-to-scene exits are the transition's job (see `../../transitions/overview.md`).
|
||||
- **Earn the sharp hold** — after the snap, the crisp element must dwell ≥1 s. The contrast between the violent streak and the still, sharp settle _is_ the effect.
|
||||
- **Heavy element, solid background** — thin type or a busy backdrop both swallow the smear. Big bold mass on a clean field reads.
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Timeline must be paused**: `gsap.timeline({ paused: true })`. Never `tl.play()`.
|
||||
- **Registry key = `data-composition-id`** on the root.
|
||||
- **No CSS `transition`** on the streaking element (or ghosts) — it interpolates independently of HF seek and causes flicker. Only GSAP drives the move and the blur.
|
||||
- **No `repeat` / `yoyo` / infinite** — a streak is a single finite arrival. Finite tweens only.
|
||||
- **No `Math.random` / `Date.now`** — ghost offsets/opacities and any stagger derive from the element index; deterministic every seek.
|
||||
- **GSAP transform aliases only**: `x`, `y`, `scale`, `rotation`. Never tween `width` / `height` / `left` / `top`. Tweening `filter` (the proxy → `stdDeviation`) and `opacity` is seek-safe and fine.
|
||||
- **Seed the SVG `stdDeviation` at setup** (Path A) — write it once before play so a seek to the first frame renders the streaked start, not a momentarily-sharp pre-frame.
|
||||
- **Filter region must be generous** (Path A) — `<filter x="-50%" y="-50%" width="200%" height="200%">` so the smear doesn't clip at the element's box edge.
|
||||
- **`overflow: hidden` on the scene** — the smear / furthest ghost extends past the resting position during travel; contain it so it doesn't bleed outside the frame.
|
||||
|
||||
## Combinations
|
||||
|
||||
- [kinetic-beat-slam.md](kinetic-beat-slam.md) — use this streak as the entrance for one phrase in a beat sequence (the scale-slam beat _is_ a motion-blur fly-in); reads its onset from the shared `BEATS[]` array
|
||||
- [center-outward-expansion.md](center-outward-expansion.md) — the grid streak-in is center-expansion with a velocity-blur envelope on each element's travel
|
||||
- [3d-text-depth-layers.md](3d-text-depth-layers.md) — extruded depth on the phrase that streaks in (depth layers ride the lead's transform)
|
||||
- [scale-swap-transition.md](scale-swap-transition.md) — alternative for a SAME-footprint state swap (this rule is for a fast ARRIVAL from off-frame / depth, not a morph)
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — `out`-family easing, proxy-driven `onUpdate` attribute tweens, and locked-envelope coordination (`../../adapters/gsap-easing-and-stagger.md`)
|
||||
- `/hyperframes-creative` — `references/typography.md` (embedded display face for a text streak), `references/video-composition.md` (solid field behind the smear)
|
||||
- `/hyperframes-core` — composition wiring, determinism (finite tweens, no `Math.random`)
|
||||
- `/hyperframes-cli` — `hyperframes lint` / `hyperframes check` (validate catches a missing `#streak-blur` node or an unreferenced filter)
|
||||
@@ -0,0 +1,273 @@
|
||||
---
|
||||
name: multi-phase-camera
|
||||
description: Sequential camera zoom with 2-3 distinct phases (pull-back / focus / push) plus continuous micro-drift for organic cinematic feel.
|
||||
metadata:
|
||||
tags: camera, zoom, phase, drift, scale, cinematic
|
||||
---
|
||||
|
||||
# Multi-Phase Camera
|
||||
|
||||
A camera wrapper around the entire scene that progresses through discrete zoom phases at scripted triggers. Continuous sine-driven micro-drift overlays so the camera never feels static between phases. Distinct from a single linear zoom — multi-phase creates "cinematic pacing" (anticipation → reveal → settle).
|
||||
|
||||
## How It Works
|
||||
|
||||
The camera is a single wrapping `<div>` whose `transform: scale() translate(x, y)` is driven by:
|
||||
|
||||
1. **Phase scale** — a stepwise scale value that advances through phases at trigger times (e.g. `PHASE_1_SCALE` at t=0 → `PHASE_2_SCALE` at PHASE_2_AT → `PHASE_3_SCALE` at PHASE_3_AT)
|
||||
2. **Drift offset** — a continuous sine-based `translateX` / `translateY` (small amplitude, slow frequency) ADDED to the phase transform
|
||||
|
||||
Both run inside the GSAP timeline so HF seeks frame-by-frame deterministically.
|
||||
|
||||
## HTML
|
||||
|
||||
```html
|
||||
<div
|
||||
class="scene"
|
||||
data-composition-id="cam-scene"
|
||||
data-start="0"
|
||||
data-duration="6"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div class="camera" id="camera">
|
||||
<div class="content">
|
||||
<div class="hero">{Brand}</div>
|
||||
<div class="tagline">{tagline}</div>
|
||||
<div class="cta">{ctaText}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS
|
||||
|
||||
```css
|
||||
.scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: {sceneBgColor};
|
||||
}
|
||||
.camera {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transform-origin: 50% 50%;
|
||||
will-change: transform;
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
.hero {
|
||||
font-family: {font};
|
||||
font-weight: 900;
|
||||
font-size: {heroSize};
|
||||
letter-spacing: 8px;
|
||||
color: {textColor};
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.tagline {
|
||||
font-family: {font};
|
||||
font-weight: 600;
|
||||
font-size: {taglineSize};
|
||||
color: {accentColor};
|
||||
}
|
||||
.cta {
|
||||
font-family: {monoFont};
|
||||
font-weight: 700;
|
||||
font-size: {ctaSize};
|
||||
letter-spacing: 6px;
|
||||
color: {accentColor};
|
||||
text-transform: uppercase;
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
const camera = document.getElementById("camera");
|
||||
|
||||
// Three-phase scale plan: pullback → focus → push
|
||||
const phase = { scale: PHASE_1_SCALE };
|
||||
|
||||
// Phase 1 — start pulled back
|
||||
// (no tween needed for the initial value; set via the phase object)
|
||||
|
||||
// Phase 2 — settle to neutral focus
|
||||
tl.to(
|
||||
phase,
|
||||
{
|
||||
scale: PHASE_2_SCALE,
|
||||
duration: PHASE_2_DUR,
|
||||
ease: PHASE_2_EASE,
|
||||
},
|
||||
PHASE_2_AT,
|
||||
);
|
||||
|
||||
// Phase 3 — slow push-in for the climax
|
||||
tl.to(
|
||||
phase,
|
||||
{
|
||||
scale: PHASE_3_SCALE,
|
||||
duration: PHASE_3_DUR,
|
||||
ease: PHASE_3_EASE,
|
||||
},
|
||||
PHASE_3_AT,
|
||||
);
|
||||
|
||||
// Drift driver — continuous sine motion overlaid on the phase scale
|
||||
const drift = { p: 0 };
|
||||
|
||||
tl.to(
|
||||
drift,
|
||||
{
|
||||
p: Math.PI * 2 * DRIFT_CYCLES,
|
||||
duration: TOTAL_DURATION,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
const dx = Math.sin(drift.p) * DRIFT_AMP_X;
|
||||
const dy = Math.sin(drift.p * DRIFT_FREQ_RATIO) * DRIFT_AMP_Y;
|
||||
camera.style.transform = `scale(${phase.scale}) translate(${dx}px, ${dy}px)`;
|
||||
},
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
// Content reveals (entry beats inside the camera frame)
|
||||
tl.from(".hero", { opacity: 0, y: 32, scale: 0.96, duration: 0.9, ease: "power3.out" }, HERO_AT);
|
||||
tl.from(".tagline", { opacity: 0, y: 16, duration: 0.7, ease: "power3.out" }, TAGLINE_AT);
|
||||
tl.from(".cta", { opacity: 0, y: 8, duration: 0.7, ease: "power3.out" }, CTA_AT);
|
||||
|
||||
window.__timelines["cam-scene"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
- **PHASE_1_SCALE / PHASE_2_SCALE / PHASE_3_SCALE** — three-step zoom values
|
||||
- Range: PHASE_1 0.88–0.96; PHASE_2 0.98–1.02; PHASE_3 1.04–1.15
|
||||
- Effects: tighter spread = subtler camera; wider = more cinematic
|
||||
- Constraints: at PHASE_1_SCALE < 1, `.scene` MUST have `overflow: hidden` or the inner content's edges leak outside the frame
|
||||
|
||||
- **PHASE_2_AT / PHASE_2_DUR** — when the focus phase starts and how long it takes
|
||||
- Range: PHASE_2_AT 0.3–1.0 s; PHASE_2_DUR 1.0–1.8 s
|
||||
- Effects: longer DUR = slower settle, more cinematic
|
||||
|
||||
- **PHASE_3_AT / PHASE_3_DUR** — when the push phase starts and how long it takes
|
||||
- Range: PHASE_3_AT 2.0–4.0 s; PHASE_3_DUR 1.0–2.0 s
|
||||
- Constraints: PHASE_3_AT must be ≥ PHASE_2_AT + PHASE_2_DUR (otherwise focus is preempted)
|
||||
|
||||
- **PHASE_2_EASE / PHASE_3_EASE** — ease per transition
|
||||
- Discrete choice: `power2.out`, `power3.out`, `power2.inOut`
|
||||
- Selection: cinematic feel; spring/back easing on a camera feels uncomfortable. Each later phase should imply more settling than the previous (longer dur OR more out-easing).
|
||||
|
||||
- **TOTAL_DURATION** — composition's total runtime (matches `data-duration`)
|
||||
- Reference: the drift tween must span the whole composition
|
||||
|
||||
- **DRIFT_CYCLES** — number of sine cycles across TOTAL_DURATION
|
||||
- Range: 1–3
|
||||
- Effects: 1 = one slow breath; 3 = noticeably busier
|
||||
- Constraints: high values read as mechanical wobble rather than organic drift
|
||||
|
||||
- **DRIFT_AMP_X / DRIFT_AMP_Y** — peak drift offset in pixels
|
||||
- Range: DRIFT_AMP_X 2–8 px; DRIFT_AMP_Y 1–4 px
|
||||
- Effects: per-frame imperceptible, visible over time. If drift is a discrete shake, it's too much.
|
||||
|
||||
- **DRIFT_FREQ_RATIO** — multiplier on the Y-axis sine frequency
|
||||
- Range: 1.2–1.5
|
||||
- Effects: 1.0 = perfect diagonal (reads mechanical); ~1.3 = organic Lissajous
|
||||
|
||||
- **HERO_AT / TAGLINE_AT / CTA_AT** — content reveal beats
|
||||
- Constraints: HERO_AT should land AFTER PHASE_1 settles via PHASE_2 (otherwise the hero feels like it's flying away while camera is still pulling back)
|
||||
|
||||
## Phase Patterns
|
||||
|
||||
| Pattern | Scale Sequence (Phase 1 → 2 → 3) | Feel | When to use |
|
||||
| ------------------- | --------------------------------- | ------------------------------- | ----------------------------- |
|
||||
| **Focus-in** | back → neutral → slight push | Approach → settle → slight push | Default product reveal |
|
||||
| **Dramatic reveal** | push → neutral → pull | Wide → focus → settle back | Hero shot with breathing room |
|
||||
| **Steady push** | neutral → slight push → more push | Gradual forward momentum | Continuous narrative push |
|
||||
| **Bookend pull** | neutral → strong push → neutral | Settle → push → release | CTA emphasis then release |
|
||||
|
||||
## Variations
|
||||
|
||||
### Phase trigger by content beat (not time)
|
||||
|
||||
If the composition has content phases (e.g. an entry completes, then orbit starts), align the camera tween start time with the content tween's end time rather than using a fixed clock value.
|
||||
|
||||
### Camera shake (panic / impact)
|
||||
|
||||
For a brief shake instead of drift, replace the drift tween with a higher-amplitude, higher-frequency one over a short window:
|
||||
|
||||
```js
|
||||
tl.to(
|
||||
drift,
|
||||
{
|
||||
p: Math.PI * 2 * SHAKE_CYCLES,
|
||||
duration: SHAKE_DUR,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
const dx = Math.sin(drift.p) * SHAKE_AMP_X;
|
||||
const dy = Math.sin(drift.p * SHAKE_FREQ_RATIO) * SHAKE_AMP_Y;
|
||||
camera.style.transform = `scale(${phase.scale}) translate(${dx}px, ${dy}px)`;
|
||||
},
|
||||
},
|
||||
SHAKE_AT,
|
||||
);
|
||||
```
|
||||
|
||||
### Targeted zoom into off-center element
|
||||
|
||||
If the climax should zoom into a non-centered element, combine scale with counter-translation. Compute the offset so the target ends at viewport center after scale:
|
||||
|
||||
```js
|
||||
const target = document.querySelector(".cta");
|
||||
const tRect = target.getBoundingClientRect();
|
||||
const viewportCenter = { x: STAGE_W / 2, y: STAGE_H / 2 };
|
||||
const offsetX = (viewportCenter.x - (tRect.left + tRect.width / 2)) / phase.scale;
|
||||
const offsetY = (viewportCenter.y - (tRect.top + tRect.height / 2)) / phase.scale;
|
||||
// then in onUpdate: translate(offsetX + dx, offsetY + dy)
|
||||
```
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Drift is imperceptible per-frame, visible over time** — if drift reads as discrete shake, the amplitude is too high
|
||||
- **Drift X and Y at slightly different frequencies** — `DRIFT_FREQ_RATIO ≈ 1.3` prevents perfect-diagonal motion, which reads as mechanical
|
||||
- **Phase springs softer than UI springs** — `power2.inOut` or `power3.out` for cinematic feel; spring/back easing on a camera feels uncomfortable
|
||||
- **Each later phase settles "deeper"** — phase 2 ease should imply more settling than phase 1 (longer duration OR more out-easing). Wakes up → settles → settles deeper
|
||||
- **Camera wraps EVERYTHING in the scene** — applying camera per-element creates parallax bugs and breaks "this is one viewpoint"
|
||||
- **❗ overflow: hidden on .scene** — phases that pull back (`scale < 1`) reveal edges of the inner content. Without `overflow: hidden`, those edges leak outside the stage frame and HF renders them as visible content
|
||||
- **❗ Hero reveal starts AFTER initial pullback ease lands** — if the camera is still pulling back when the headline fades in, the headline feels like it's flying away
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Timeline must be paused**: `gsap.timeline({ paused: true })`
|
||||
- **Registry key = `data-composition-id`**
|
||||
- **No CSS `transition` on `.camera`** — competes with the GSAP transform
|
||||
- **`transform-origin: 50% 50%`** on camera — off-center origin creates unpredictable phase-to-phase drift
|
||||
- **`will-change: transform`** on `.camera` — the camera transform updates every frame
|
||||
- **`overflow: hidden` on `.scene`** — required when any phase scale < 1
|
||||
- **Scene background on `.scene`, not `.camera`** — if background is on camera, scaling/translating it reveals the outer void
|
||||
|
||||
## Combinations
|
||||
|
||||
- [orbit-3d-entry.md](orbit-3d-entry.md) — orbit motion inside a slowly drifting camera
|
||||
- [counting-dynamic-scale.md](counting-dynamic-scale.md) — climax phase push synced to counter peak
|
||||
- [3d-text-depth-layers.md](3d-text-depth-layers.md) — depth-stacked hero with cinematic camera moves
|
||||
- [sine-wave-loop.md](sine-wave-loop.md) — element idle inside the camera (compound motion)
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — multi-phase tween + drift onUpdate
|
||||
- `/hyperframes-core` — composition wiring, scene wrapper
|
||||
- `/hyperframes-cli` — `hyperframes lint`
|
||||
@@ -0,0 +1,301 @@
|
||||
---
|
||||
name: orbit-3d-entry
|
||||
description: Elements flip in from 3D space then settle into continuous elliptical orbit around a focal point.
|
||||
metadata:
|
||||
tags: orbit, 3d, flip, ellipse, circular, icon, entry, continuous
|
||||
---
|
||||
|
||||
# Orbit with 3D Entry
|
||||
|
||||
Elements flip in from 3D space (rotateX + rotateY + translateZ) then transition into a continuous elliptical orbit around a focal point. Distinct from one-shot reveals — the orbit keeps running.
|
||||
|
||||
## How It Works
|
||||
|
||||
Two phases per element:
|
||||
|
||||
1. **Entry (per element)**: GSAP tween from hidden 3D orientation (`rotateX`, `rotateY`, negative `z`) to flat (`rotateX: 0, rotateY: 0, z: 0`). Spring-like ease (`back.out`) for the flip-in.
|
||||
2. **Orbit (after entry)**: Continuous trigonometric position around a center point. The element's `x` and `y` translate are driven by `cos(t)` and `sin(t)` at a slow angular speed.
|
||||
|
||||
The orbit runs **inside the timeline** — not via `requestAnimationFrame` — so HF seek-by-frame stays deterministic.
|
||||
|
||||
## HTML
|
||||
|
||||
```html
|
||||
<div
|
||||
class="scene"
|
||||
id="orbit-scene"
|
||||
data-composition-id="orbit-scene"
|
||||
data-start="0"
|
||||
data-duration="5"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div class="orbit-stage">
|
||||
<div class="orbit-item" data-angle="0">{glyph1}</div>
|
||||
<div class="orbit-item" data-angle="60">{glyph2}</div>
|
||||
<div class="orbit-item" data-angle="120">{glyph3}</div>
|
||||
<div class="orbit-item" data-angle="180">{glyph4}</div>
|
||||
<div class="orbit-item" data-angle="240">{glyph5}</div>
|
||||
<div class="orbit-item" data-angle="300">{glyph6}</div>
|
||||
<div class="orbit-center">{centerLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS
|
||||
|
||||
```css
|
||||
.scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: {sceneBackground};
|
||||
perspective: 1800px; /* REQUIRED — without perspective, rotateX/Y flatten */
|
||||
}
|
||||
.orbit-stage {
|
||||
position: relative;
|
||||
width: 1000px;
|
||||
height: 700px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
.orbit-item {
|
||||
position: absolute;
|
||||
/* Items live at stage center; GSAP translates them along the orbit. */
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: {accentColor};
|
||||
border-radius: 50%;
|
||||
font-family: {font};
|
||||
font-weight: 900;
|
||||
font-size: 64px;
|
||||
color: {itemTextColor};
|
||||
transform-style: preserve-3d;
|
||||
will-change: transform;
|
||||
box-shadow: 0 12px 36px {accentShadowColor};
|
||||
}
|
||||
.orbit-center {
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
font-family: {font};
|
||||
font-weight: 900;
|
||||
font-size: 96px;
|
||||
letter-spacing: 8px;
|
||||
color: {centerTextColor};
|
||||
text-transform: uppercase;
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
const items = document.querySelectorAll(".orbit-item");
|
||||
// RADIUS_X, RADIUS_Y, ORBIT_DURATION, ENTRY_DUR, STAGGER, FLIP_BACK, CENTER_BACK
|
||||
// — all named constants; values per "How to Choose Values" below.
|
||||
const RADIUS_Y = RADIUS_X * Y_TO_X_RATIO; // perspective-flattened ellipse
|
||||
|
||||
items.forEach((el, i) => {
|
||||
const initialAngleDeg = Number(el.dataset.angle);
|
||||
const initialAngleRad = (initialAngleDeg / 360) * Math.PI * 2;
|
||||
const startX = Math.cos(initialAngleRad) * RADIUS_X;
|
||||
const startY = Math.sin(initialAngleRad) * RADIUS_Y;
|
||||
|
||||
// 1) Place at orbital position with opacity 0 — BEFORE any tween fires
|
||||
gsap.set(el, {
|
||||
xPercent: -50,
|
||||
yPercent: -50,
|
||||
x: startX,
|
||||
y: startY,
|
||||
rotateX: ROTATE_X_FROM,
|
||||
rotateY: ROTATE_Y_FROM,
|
||||
z: Z_FROM,
|
||||
opacity: 0,
|
||||
scale: SCALE_FROM,
|
||||
});
|
||||
|
||||
// 2) Phase 1 — flip in IN PLACE at orbital position
|
||||
tl.to(
|
||||
el,
|
||||
{
|
||||
rotateX: 0,
|
||||
rotateY: 0,
|
||||
z: 0,
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
duration: ENTRY_DUR,
|
||||
ease: `back.out(${FLIP_BACK})`,
|
||||
},
|
||||
i * STAGGER,
|
||||
);
|
||||
|
||||
// 3) Phase 2 — continuous orbit driven via a 0→1 progress tween
|
||||
const orbitState = { p: 0 };
|
||||
tl.to(
|
||||
orbitState,
|
||||
{
|
||||
p: 1,
|
||||
duration: ORBIT_DURATION,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
const angle = initialAngleRad + orbitState.p * Math.PI * 2;
|
||||
const x = Math.cos(angle) * RADIUS_X;
|
||||
const y = Math.sin(angle) * RADIUS_Y;
|
||||
// z-index by orbit Y — see "Center label clearance" in Key Principles
|
||||
// for the capped-range form when a center label is present.
|
||||
el.style.zIndex = String(Math.round(y + RADIUS_Y));
|
||||
el.style.transform = `translate(-50%, -50%) translate(${x}px, ${y}px)`;
|
||||
},
|
||||
},
|
||||
i * STAGGER + ENTRY_DUR,
|
||||
);
|
||||
});
|
||||
|
||||
// Center label fades in once a few orbit items have landed
|
||||
tl.from(
|
||||
".orbit-center",
|
||||
{ opacity: 0, scale: 0.6, duration: ENTRY_DUR, ease: `back.out(${CENTER_BACK})` },
|
||||
CENTER_FADE_AT,
|
||||
);
|
||||
|
||||
window.__timelines["orbit-scene"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
- **RADIUS_X** — horizontal radius of the orbit ellipse, in px
|
||||
- Range: 300–900 px
|
||||
- Effects: small radius reads as a tight cluster; large radius spreads the ring across the frame and lets a large center element breathe
|
||||
- Constraints: must clear the center element horizontally at every angle — see Key Principles for the `RADIUS_X * min(|cos(θ)|) ≥ L_w + I_w + breathing_room` rule
|
||||
- Reference: ../../examples/cta-orbit-collapse.html uses 480
|
||||
|
||||
- **Y_TO_X_RATIO** — `RADIUS_Y / RADIUS_X`, the orbit's perspective flattening
|
||||
- Range: 0.4–0.7
|
||||
- Effects: low values read as a near-horizontal disc seen from above; values approaching 1 read as a flat plane facing the camera
|
||||
- Constraints: keep < 1 — the orbit should look like a tilted ring, not a frontal halo
|
||||
- Reference: ../../examples/cta-orbit-collapse.html uses ≈ 0.58
|
||||
|
||||
- **ORBIT_DURATION** — seconds for one full revolution
|
||||
- Range: 4–25 s (longer for ambient backdrop, shorter for active feature motion)
|
||||
- Effects: short durations look frenetic; long durations read as drifting / calm
|
||||
- Constraints: must be ≥ the time the orbit is on screen, otherwise the tween ends and items stop
|
||||
- Reference: ../../examples/cta-orbit-collapse.html uses ~25 s effective (orbit speed 0.25 rad/s)
|
||||
|
||||
- **ENTRY_DUR** — per-element flip-in duration
|
||||
- Range: 0.4–0.8 s
|
||||
- Effects: short feels punchy; long feels stately
|
||||
- Constraints: must be ≤ the gap between the first and last element's start so the cascade doesn't overlap to incoherence
|
||||
- Reference: ../../examples/cta-orbit-collapse.html uses 0.55 s
|
||||
|
||||
- **STAGGER** — delay between consecutive element entries
|
||||
- Range: 0.06–0.12 s
|
||||
- Effects: below ~0.06 s reads as "popcorn"; above ~0.12 s reads as plodding
|
||||
- Constraints: total cascade `(n - 1) * STAGGER` should still complete before the next scene phase begins
|
||||
- Reference: ../../examples/cta-orbit-collapse.html uses 0.10 s
|
||||
|
||||
- **FLIP_BACK** — `back.out(<n>)` overshoot for the flip-in
|
||||
- Range: 1.2–2.0
|
||||
- Effects: low end is a soft arrive; high end snaps with visible overshoot
|
||||
- Constraints: pair with a calmer `CENTER_BACK` if both fire close together — competing overshoots cancel each other
|
||||
- Reference: ../../examples/cta-orbit-collapse.html uses 1.4
|
||||
|
||||
- **CENTER_BACK** — `back.out(<n>)` overshoot for the center label fade-in
|
||||
- Range: 1.2–1.8
|
||||
- Effects: low end keeps the label calm under the busy orbit; high end gives it a small "pop" of arrival
|
||||
- Reference: ../../examples/cta-orbit-collapse.html uses 1.4
|
||||
|
||||
- **CENTER_FADE_AT** — when the center label fades in, in seconds
|
||||
- Range: just after the first 2–4 elements have landed
|
||||
- Effects: too early competes with the cascade; too late leaves a hole at the center of the orbit
|
||||
- Reference: ../../examples/cta-orbit-collapse.html starts the center brand near the front of the scene
|
||||
|
||||
- **ROTATE_X_FROM / ROTATE_Y_FROM / Z_FROM / SCALE_FROM** — initial 3D orientation
|
||||
- Range: rotateX ±60° to ±120°; rotateY ±45° to ±120°; z −200 to −400; scale 0.2–0.6
|
||||
- Effects: higher absolute rotation + deeper negative z = more dramatic "card flipping out of depth"; lower = subtle reorientation
|
||||
- Constraints: pick a direction consistent with the scene's perspective; mixing positive and negative rotateY across items reads as noise
|
||||
- Reference: ../../examples/cta-orbit-collapse.html uses rotateX 90, rotateY −45, z −100, scale 0
|
||||
|
||||
## Variations
|
||||
|
||||
### Collapse to center
|
||||
|
||||
To reverse — orbit then collapse inward — interpolate `RADIUS_X` and `RADIUS_Y` to 0 in a final phase by multiplying both radii by a 1→0 driver:
|
||||
|
||||
```js
|
||||
const collapse = { r: 1 };
|
||||
tl.to(
|
||||
collapse,
|
||||
{
|
||||
r: 0,
|
||||
duration: COLLAPSE_DUR,
|
||||
ease: "power3.inOut",
|
||||
onUpdate: () =>
|
||||
items.forEach((el) => {
|
||||
const a = (Number(el.dataset.angle) / 360) * Math.PI * 2;
|
||||
const x = Math.cos(a) * RADIUS_X * collapse.r;
|
||||
const y = Math.sin(a) * RADIUS_Y * collapse.r;
|
||||
el.style.transform = `translate(-50%,-50%) translate(${x}px,${y}px) scale(${collapse.r})`;
|
||||
}),
|
||||
},
|
||||
COLLAPSE_AT,
|
||||
);
|
||||
```
|
||||
|
||||
### Tilted orbit plane
|
||||
|
||||
For a more dramatic 3D orbit, rotate the entire `.orbit-stage` on the X axis:
|
||||
|
||||
```css
|
||||
.orbit-stage {
|
||||
transform: rotateX(25deg);
|
||||
}
|
||||
```
|
||||
|
||||
Items rendered above/below the equator visually arc through the plane.
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **`perspective` on scene root REQUIRED** — without it, rotateX/Y read as 2D scale and the flip-in looks flat
|
||||
- **`transform-style: preserve-3d`** on both the stage and each item — preserves the 3D context as items have their own transforms
|
||||
- **Stagger entries** — cascade reads as "swarm forming," simultaneous reads as "popcorn." See `STAGGER` in How to Choose Values
|
||||
- **Element count 4-12** — fewer feels empty, more crowds the center
|
||||
- **❗ Center label clearance — translateZ + capped item z-index** — `z-index` ALONE is unreliable inside a `transform-style: preserve-3d` stage (paint order follows Z position, not stacking-context z-index). For the orbit to NEVER occlude the headline:
|
||||
1. Push the center label forward: `transform: translateZ(220px); z-index: 9999;`
|
||||
2. Cap orbit-item dynamic z-index in `[1, 50]` so bottom-of-orbit items still read as "in front of" top-of-orbit items, but **never above the center label**. e.g.: `el.style.zIndex = String(1 + Math.round((y + RADIUS_Y) / (2 * RADIUS_Y) * 49));`
|
||||
3. **Choose `RADIUS_X` so items also clear the center label HORIZONTALLY at all angles.** If the label's half-width is `L_w` and the item's half-width is `I_w`, then `RADIUS_X` must satisfy `RADIUS_X * min(|cos(θ_minimum)|) ≥ L_w + I_w + breathing_room`. For a 6-item orbit with 60° angular spacing, the worst case is `cos(30°) ≈ 0.866` between items. Scale `RADIUS_X` with the center label's width — a heavier wordmark needs a wider ring.
|
||||
- **❗ Center element is the headline** — the orbit is ornamental motion around it. If the orbit dominates the eye, increase center element size or fade orbit items down
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **No `requestAnimationFrame`** — orbit must run inside the timeline so HF seeks frame-by-frame deterministically
|
||||
- **Timeline must be paused**: `gsap.timeline({ paused: true })`
|
||||
- **Registry key = `data-composition-id`**
|
||||
- **Each item gets its OWN orbit tween** — don't share one tween with `targets: '.orbit-item'` because each starts at a different `initialAngle`
|
||||
- **`will-change: transform`** — many simultaneous orbital transforms benefit from compositor hints
|
||||
- **Don't animate `left`/`top`** — use `translate()` (composes with `translate(-50%, -50%)` centering)
|
||||
- **❗ Entry must flip IN PLACE at orbital position, NOT at center** — a fromTo whose "from" and "to" both have `x: 0, y: 0` keeps the item at the stage center during phase 1, so it collides with the center label during flip-in (and then snaps to orbit on phase 2 start — a visible teleport).
|
||||
|
||||
The correct pattern (see GSAP Timeline above) is to `gsap.set()` each item at `(cos(initialAngle)*RADIUS_X, sin(initialAngle)*RADIUS_Y)` with `opacity: 0` BEFORE adding tweens, then have phase 1 animate only rotation/opacity/scale — NOT translate. The item fades in IN PLACE at its orbital starting point, and phase 2 picks up the orbit smoothly from there.
|
||||
|
||||
## Combinations
|
||||
|
||||
- [center-outward-expansion.md](center-outward-expansion.md) — alternative entry pattern (burst, not orbit); also the reversed driver for an orbit-collapse finish
|
||||
- [cursor-click-ripple.md](cursor-click-ripple.md) — pairs naturally when the center element is a CTA the user "clicks" to trigger the collapse
|
||||
- [sine-wave-loop.md](sine-wave-loop.md) — per-item idle wobble layered on top of the orbit
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — timeline + `onUpdate` API
|
||||
- `/hyperframes-core` — composition wiring
|
||||
- `/hyperframes-cli` — `hyperframes lint`
|
||||
@@ -0,0 +1,350 @@
|
||||
---
|
||||
name: physics-press-reaction
|
||||
description: Cursor + element synchronized press via subtractive spring forces — cursor lands on element, both compress together, then release. Distinct from press-release-spring (which has no cursor).
|
||||
metadata:
|
||||
tags: spring, click, physics, cursor, subtractive, interaction, synchronized
|
||||
---
|
||||
|
||||
# Physics Press Reaction (Cursor + Element Synced)
|
||||
|
||||
Models a real click: a cursor approaches a button, lands, and both compress IN SYNC, then release together. Two distinct timing events (down-frame and up-frame) bound by spring forces. Distinct from [press-release-spring](press-release-spring.md) (which has no cursor — just a press happening); this rule is the COMBINED cursor + element behavior.
|
||||
|
||||
## How It Works
|
||||
|
||||
A single `PRESS_INTENSITY` value drives both cursor and button together:
|
||||
|
||||
- **press down**: both compress to `1 - PRESS_INTENSITY`
|
||||
- **release**: both spring back to 1.0 with overshoot
|
||||
|
||||
The cursor ALSO translates to the button's center during the approach phase BEFORE press starts. After release, the cursor may move on (next interaction) or hold.
|
||||
|
||||
## HTML
|
||||
|
||||
```html
|
||||
<div
|
||||
class="scene"
|
||||
id="press-react-scene"
|
||||
data-composition-id="press-react-scene"
|
||||
data-start="0"
|
||||
data-duration="3"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div class="stack">
|
||||
<button class="btn" id="btn">
|
||||
<span class="btn-icon">{ctaIcon}</span>
|
||||
<span class="btn-label">{ctaCopy}</span>
|
||||
</button>
|
||||
<div class="brand">{Brand}</div>
|
||||
</div>
|
||||
<!-- Cursor lives at scene-root level so it can translate freely -->
|
||||
<svg class="cursor" id="cursor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M3 2 L21 12 L12 13 L7 22 Z"
|
||||
fill="{cursorFill}"
|
||||
stroke="{cursorStroke}"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS
|
||||
|
||||
```css
|
||||
.scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: {sceneBg};
|
||||
font-family: {font};
|
||||
overflow: hidden;
|
||||
}
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: STACK_GAP;
|
||||
}
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: BTN_INNER_GAP;
|
||||
padding: BTN_PADDING_V BTN_PADDING_H;
|
||||
background: {btnBg};
|
||||
border: none;
|
||||
border-radius: BTN_RADIUS;
|
||||
color: {btnTextColor};
|
||||
font-family: {font};
|
||||
font-weight: 900;
|
||||
font-size: BTN_FONT_SIZE;
|
||||
letter-spacing: BTN_TRACKING;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
box-shadow: {btnRestingShadow};
|
||||
transform-origin: 50% 50%;
|
||||
will-change: transform;
|
||||
}
|
||||
.btn-icon {
|
||||
font-size: BTN_ICON_SIZE;
|
||||
line-height: 1;
|
||||
}
|
||||
.brand {
|
||||
font-size: BRAND_SIZE;
|
||||
font-weight: 800;
|
||||
letter-spacing: BRAND_TRACKING;
|
||||
color: {brandColor};
|
||||
text-transform: uppercase;
|
||||
}
|
||||
/* Cursor — absolute, positioned by GSAP */
|
||||
.cursor {
|
||||
position: absolute;
|
||||
width: CURSOR_SIZE;
|
||||
height: CURSOR_SIZE;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
/* initial position is set by gsap.set() */
|
||||
transform-origin: 0 0; /* arrow point is the click point */
|
||||
filter: {cursorDropShadow};
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// Position cursor initially off-target (off-screen or far corner).
|
||||
gsap.set("#cursor", { x: CURSOR_START_X, y: CURSOR_START_Y });
|
||||
|
||||
// The button's screen center, in composition coordinates.
|
||||
const BUTTON_CENTER = { x: BUTTON_CENTER_X, y: BUTTON_CENTER_Y };
|
||||
|
||||
// Phase 1 — cursor approaches button
|
||||
tl.to(
|
||||
"#cursor",
|
||||
{
|
||||
x: BUTTON_CENTER.x,
|
||||
y: BUTTON_CENTER.y,
|
||||
duration: APPROACH_DUR,
|
||||
ease: "power2.inOut",
|
||||
},
|
||||
APPROACH_START,
|
||||
);
|
||||
|
||||
// Phase 2 — coordinated press down (button + cursor both scale to 1 - PRESS_INTENSITY)
|
||||
tl.to(
|
||||
["#btn", "#cursor"],
|
||||
{
|
||||
scale: 1 - PRESS_INTENSITY,
|
||||
duration: PRESS_DOWN_DUR,
|
||||
ease: "power1.in",
|
||||
},
|
||||
PRESS_DOWN_AT,
|
||||
);
|
||||
|
||||
// Phase 3 — release (both spring back to 1.0 with overshoot)
|
||||
tl.to(
|
||||
["#btn", "#cursor"],
|
||||
{
|
||||
scale: 1,
|
||||
duration: RELEASE_DUR,
|
||||
ease: `back.out(${BOUNCE_FACTOR})`,
|
||||
},
|
||||
RELEASE_AT,
|
||||
);
|
||||
|
||||
// Phase 4 — inner glow during press (boxShadow change synced to press scale)
|
||||
tl.to(
|
||||
"#btn",
|
||||
{
|
||||
boxShadow: `{btnPressedShadow}`,
|
||||
duration: PRESS_DOWN_DUR,
|
||||
ease: "power1.in",
|
||||
},
|
||||
PRESS_DOWN_AT,
|
||||
);
|
||||
tl.to(
|
||||
"#btn",
|
||||
{
|
||||
boxShadow: `{btnRestingShadow}`,
|
||||
duration: RELEASE_DUR,
|
||||
ease: "power2.out",
|
||||
},
|
||||
RELEASE_AT,
|
||||
);
|
||||
|
||||
// Brand fades in early (context)
|
||||
tl.from(
|
||||
".brand",
|
||||
{ opacity: 0, y: BRAND_REVEAL_Y_PX, duration: BRAND_REVEAL_DUR, ease: "power3.out" },
|
||||
BRAND_REVEAL_AT,
|
||||
);
|
||||
|
||||
// Cursor optionally moves off after press (or holds for dwell)
|
||||
tl.to(
|
||||
"#cursor",
|
||||
{ x: CURSOR_EXIT_X, y: CURSOR_EXIT_Y, duration: CURSOR_EXIT_DUR, ease: "power2.out" },
|
||||
CURSOR_EXIT_AT,
|
||||
);
|
||||
|
||||
window.__timelines["press-react-scene"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
### Multiple-element chain press
|
||||
|
||||
Cursor presses button A → button A triggers swap → cursor moves to button B → presses again. Each press is one full down-release sub-routine.
|
||||
|
||||
### Hold press (continuous pressure)
|
||||
|
||||
Insert a `HOLD_DUR` window between press-down and release. Cursor scale stays at `1 - PRESS_INTENSITY`, button scale stays at `1 - PRESS_INTENSITY`, inner glow stays on. Suggests "thinking" or "loading."
|
||||
|
||||
### Synchronized inner-glow pulse
|
||||
|
||||
During the hold phase, the inner glow pulses (sin-driven). Suggests "processing":
|
||||
|
||||
```js
|
||||
const holdGlow = { p: 0 };
|
||||
tl.to(
|
||||
holdGlow,
|
||||
{
|
||||
p: Math.PI * GLOW_PULSE_CYCLES * 2,
|
||||
duration: HOLD_DUR,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
const alpha = GLOW_BASE_ALPHA + Math.sin(holdGlow.p) * GLOW_PULSE_AMP;
|
||||
document.getElementById("btn").style.boxShadow =
|
||||
`inset 0 0 GLOW_BLUR rgba(255, 255, 255, ${alpha})`;
|
||||
},
|
||||
},
|
||||
HOLD_START_AT,
|
||||
);
|
||||
```
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
### Timing (seconds)
|
||||
|
||||
- **APPROACH_START** — when the cursor begins moving toward the button.
|
||||
- Range: 0-0.3 s (small lead-in is fine; long delays read as a dead frame)
|
||||
- **APPROACH_DUR** — cursor approach duration.
|
||||
- Range: 0.7-1.3 s; faster reads as urgent, slower as deliberate
|
||||
- **PRESS_DOWN_AT** — when the press fires.
|
||||
- Constraints: MUST equal `APPROACH_START + APPROACH_DUR` so the cursor arrives exactly when the press begins (avoids "tapping on air")
|
||||
- **PRESS_DOWN_DUR** — compression duration.
|
||||
- Range: 0.1-0.25 s
|
||||
- **RELEASE_AT** — when the release fires.
|
||||
- Constraints: must be > `PRESS_DOWN_AT + PRESS_DOWN_DUR`; an optional brief hold (0.05-0.4 s, or `HOLD_DUR` for the Hold-press variation) for "thinking" interactions
|
||||
- **RELEASE_DUR** — release spring duration.
|
||||
- Range: 0.4-0.7 s (long enough for the overshoot to settle)
|
||||
- **BRAND_REVEAL_AT** — when the brand line fades in.
|
||||
- Constraints: must be < `PRESS_DOWN_AT` (context precedes interaction)
|
||||
- **BRAND_REVEAL_DUR** — brand fade-in duration.
|
||||
- Range: 0.4-0.8 s
|
||||
- **CURSOR_EXIT_AT / CURSOR_EXIT_DUR** — optional outbound cursor motion after release.
|
||||
- Constraints: `CURSOR_EXIT_AT` must be ≥ `RELEASE_AT + RELEASE_DUR` so the cursor exits AFTER the press settles, not during
|
||||
|
||||
### Physics
|
||||
|
||||
- **PRESS_INTENSITY** — how deep the press compression goes.
|
||||
- Range: 0.05 (subtle) - 0.10 (standard) - 0.15 (heavy)
|
||||
- Applied as `scale: 1 - PRESS_INTENSITY` on both cursor and button (single GSAP target array)
|
||||
- **BOUNCE_FACTOR** — `back.out(${BOUNCE_FACTOR})` overshoot on the release.
|
||||
- Range: 1.6 (soft) - 2.0 (firm) - 2.4 (cartoony)
|
||||
|
||||
### Positioning
|
||||
|
||||
- **CURSOR_START_X / CURSOR_START_Y** — initial cursor position in composition coordinates.
|
||||
- Constraints: off-screen or in a corner far from the button so the approach reads as motion-in, not a teleport
|
||||
- **BUTTON_CENTER_X / BUTTON_CENTER_Y** — the button's measured screen-space center.
|
||||
- Source: measured at composition coordinates; for `place-items: center` at 1920×1080 this is `(960, 540)`
|
||||
- **CURSOR_EXIT_X / CURSOR_EXIT_Y** — where the cursor moves after release (if used).
|
||||
- Range: any off-stage or out-of-the-way position
|
||||
- **BRAND_REVEAL_Y_PX** — brand initial y offset.
|
||||
- Range: 8-20 px
|
||||
|
||||
### Layout / typography
|
||||
|
||||
- **STACK_GAP** — gap between button and brand line.
|
||||
- Range: 40-96 px
|
||||
- **BTN_PADDING_V / BTN_PADDING_H** — button padding.
|
||||
- Range: V 24-40 px, H 60-100 px (horizontal padding 2-3× vertical reads as pill-shaped CTA)
|
||||
- **BTN_INNER_GAP** — gap between icon and label inside the button.
|
||||
- Range: 16-32 px
|
||||
- **BTN_RADIUS** — button corner radius.
|
||||
- Range: 20-40 px, or `BTN_PADDING_V + BTN_FONT_SIZE/2` for fully rounded ends
|
||||
- **BTN_FONT_SIZE / BTN_ICON_SIZE** — typographic sizes inside the button.
|
||||
- Range: font 60-100 px at 1080p; icon ~1.0-1.1× font size
|
||||
- **BTN_TRACKING** — letter-spacing on uppercase button text.
|
||||
- Range: 4-12 px
|
||||
- **BRAND_SIZE / BRAND_TRACKING** — brand line typography.
|
||||
- Range: 40-60 px, tracking 8-16 px
|
||||
- **CURSOR_SIZE** — cursor SVG size.
|
||||
- Range: 48-96 px at 1080p
|
||||
|
||||
### Hold-press variation
|
||||
|
||||
- **HOLD_DUR** — hold window between press down and release.
|
||||
- Range: 0.3-0.8 s
|
||||
- **HOLD_START_AT** — when the glow pulse begins.
|
||||
- Constraints: typically equal to `PRESS_DOWN_AT + PRESS_DOWN_DUR`
|
||||
- **GLOW_PULSE_CYCLES** — number of full sine cycles across `HOLD_DUR`.
|
||||
- Range: 1-4 (more cycles read as faster "processing")
|
||||
- **GLOW_BASE_ALPHA** — center of the alpha pulse.
|
||||
- Range: 0.15-0.3
|
||||
- **GLOW_PULSE_AMP** — peak deviation from `GLOW_BASE_ALPHA`.
|
||||
- Range: 0.1-0.2; must satisfy `GLOW_BASE_ALPHA - GLOW_PULSE_AMP ≥ 0`
|
||||
- **GLOW_BLUR** — inset glow blur radius (px).
|
||||
- Range: 24-48 px
|
||||
|
||||
### Tokens
|
||||
|
||||
- **{sceneBg}** — background gradient/color
|
||||
- **{font}** — typographic stack
|
||||
- **{btnBg}** — button background (typically gradient toward an accent hue)
|
||||
- **{btnTextColor}** — button text color
|
||||
- **{btnRestingShadow}** / **{btnPressedShadow}** — outer + inset box-shadow strings for the resting and pressed states
|
||||
- **{brandColor}** — accent brand color
|
||||
- **{cursorFill}** / **{cursorStroke}** — cursor SVG fill and stroke
|
||||
- **{cursorDropShadow}** — `filter: drop-shadow(...)` value for cursor depth
|
||||
- **{Brand}** — brand line copy
|
||||
- **{ctaCopy}** / **{ctaIcon}** — button label and inline icon glyph
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Same press scale on cursor AND button** — physical synchronicity. If only the button scales, the cursor appears to "tap on air"; if only the cursor scales, the button feels disconnected.
|
||||
- **Cursor arrives BEFORE press starts** — there must be a clear moment of "cursor over target" before scale change. Otherwise the press is unattributed.
|
||||
- **`back.out(${BOUNCE_FACTOR})` for release** — both elements need spring overshoot together. Linear release loses the tactile feel.
|
||||
- **Inner glow appears DURING press, fades on release** — visual confirmation of contact. Outer shadow shrinks (pushed-in), inner glow appears (energy concentrated).
|
||||
- **Cursor `pointer-events: none`** — the cursor is decorative; if it captures events, hover/click behaviors on button below break.
|
||||
- **Cursor `transform-origin: 0 0`** — the arrow's tip is the click point, not its center. Scale around the tip keeps the click point stable.
|
||||
- **Climax dwell ≥1 s** — after release, the comp must continue ≥1 s. The press is a beat; viewer needs time to see the result.
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Timeline must be paused**: `gsap.timeline({ paused: true })`
|
||||
- **Registry key = `data-composition-id`**
|
||||
- **No CSS `transition`** on either cursor or button — competes with GSAP
|
||||
- **Cursor SVG with `pointer-events: none`**
|
||||
- **`will-change: transform`** on button (and cursor if desired)
|
||||
- **`up-frame > down-frame`** — release MUST come after press; otherwise the comp shows release without press
|
||||
- **Don't use real `mouseenter` / `click` events** — HF is a render context, not a UI; everything must run via the timeline
|
||||
|
||||
## Combinations
|
||||
|
||||
- [press-release-spring.md](press-release-spring.md) — the BUTTON-only press variant; this rule layers cursor on top
|
||||
- [cursor-click-ripple.md](cursor-click-ripple.md) — adds a ripple effect at the click point
|
||||
- [scale-swap-transition.md](scale-swap-transition.md) — the press TRIGGERS the swap
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — coordinated multi-target tweens via array
|
||||
- `/hyperframes-core` — composition wiring
|
||||
- `/hyperframes-cli` — `hyperframes lint`
|
||||
@@ -0,0 +1,296 @@
|
||||
---
|
||||
name: press-release-spring
|
||||
description: Tactile button press with linear compression, spring-based elastic recovery, and layered visual feedback (shadow shrink + release burst + background glow).
|
||||
metadata:
|
||||
tags: spring, press, interaction, button, physics, glow, burst, ui
|
||||
---
|
||||
|
||||
# Press-Release Spring Chain
|
||||
|
||||
Separates input (linear compression) from output (spring recovery) to create tactile feel. The overshoot is a natural byproduct of the spring config, not manually coded. Pairs with secondary motion (shadow shrink, release burst, background glow) layered on the same trigger frame.
|
||||
|
||||
## How It Works
|
||||
|
||||
Two distinct phases split at the **release** moment:
|
||||
|
||||
1. **Press**: linear ease → compression (`scale: 1 → PRESS_SCALE`, shadow shrinks). Linear, not spring — the dip must read as instant/tactile, not squishy.
|
||||
2. **Release**: `back.out(${BOUNCE_FACTOR})` spring → elastic pop back to `1.0` (overshoot proportional to `BOUNCE_FACTOR`). Optional burst glow ring expands behind the button; optional background environmental glow fades in.
|
||||
|
||||
State continuity is critical: the release tween's start value MUST equal the press tween's end value, or the spring snaps to a different position. GSAP threads this automatically when both tweens target the same property at adjacent positions on the same timeline.
|
||||
|
||||
## HTML
|
||||
|
||||
```html
|
||||
<div
|
||||
class="scene"
|
||||
id="press-scene"
|
||||
data-composition-id="press-scene"
|
||||
data-start="0"
|
||||
data-duration="DURATION"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div class="press-stage">
|
||||
<div class="bg-glow" id="bg-glow"></div>
|
||||
<div class="burst" id="burst"></div>
|
||||
<button class="btn" id="btn">{buttonLabel}</button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS
|
||||
|
||||
```css
|
||||
.scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: {bgColor};
|
||||
}
|
||||
.press-stage {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.btn {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
/* Visual weight: ≥4% of canvas for the press to read on a 1080p frame */
|
||||
width: BTN_WIDTH;
|
||||
height: BTN_HEIGHT;
|
||||
background: {btnBg};
|
||||
border: none;
|
||||
border-radius: BTN_RADIUS;
|
||||
font-family: {font};
|
||||
font-weight: 900;
|
||||
font-size: BTN_FONT_SIZE;
|
||||
letter-spacing: BTN_LETTER_SPACING;
|
||||
color: {btnTextColor};
|
||||
text-transform: uppercase;
|
||||
/* Anchor compression on the center — see Critical Constraints */
|
||||
transform-origin: 50% 50%;
|
||||
/* Initial floating shadow — large + diffuse */
|
||||
box-shadow: {btnRestShadow};
|
||||
}
|
||||
.burst {
|
||||
/* Sits BEHIND the button, same footprint */
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset: 0;
|
||||
width: BTN_WIDTH;
|
||||
height: BTN_HEIGHT;
|
||||
background: {burstGradient};
|
||||
filter: blur(BURST_BLUR);
|
||||
opacity: 0;
|
||||
transform: scale(1);
|
||||
pointer-events: none;
|
||||
}
|
||||
.bg-glow {
|
||||
/* Full-stage radial — extends beyond the stage with negative inset */
|
||||
position: absolute;
|
||||
inset: BG_GLOW_INSET;
|
||||
background: {bgGlowGradient};
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// Phase 1 — press (linear compression)
|
||||
tl.to(
|
||||
"#btn",
|
||||
{
|
||||
scale: PRESS_SCALE,
|
||||
boxShadow: "{btnPressedShadow}",
|
||||
duration: PRESS_DUR,
|
||||
ease: "power1.in",
|
||||
},
|
||||
PRESS_START,
|
||||
);
|
||||
|
||||
// Phase 2 — release (spring back with overshoot)
|
||||
// CRITICAL: start scale == end of phase 1 (PRESS_SCALE) to maintain state continuity.
|
||||
tl.to(
|
||||
"#btn",
|
||||
{
|
||||
scale: 1,
|
||||
boxShadow: "{btnRestShadow}",
|
||||
duration: RELEASE_DUR,
|
||||
ease: `back.out(${BOUNCE_FACTOR})`,
|
||||
},
|
||||
RELEASE_START,
|
||||
);
|
||||
|
||||
// Phase 3 — burst glow (radial pop behind button), triggered with release
|
||||
tl.fromTo(
|
||||
"#burst",
|
||||
{ scale: 1, opacity: 0 },
|
||||
{
|
||||
scale: BURST_PEAK_SCALE,
|
||||
opacity: BURST_PEAK_OPACITY,
|
||||
duration: BURST_GROW_DUR,
|
||||
ease: "power2.out",
|
||||
},
|
||||
RELEASE_START,
|
||||
);
|
||||
// Burst then fades out
|
||||
tl.to("#burst", { opacity: 0, duration: BURST_FADE_DUR, ease: "power2.in" }, BURST_FADE_START);
|
||||
|
||||
// Phase 4 — background environmental glow fades in after release
|
||||
tl.to(
|
||||
"#bg-glow",
|
||||
{
|
||||
opacity: BG_GLOW_PEAK_OPACITY,
|
||||
duration: BG_GLOW_FADE_DUR,
|
||||
ease: "power2.out",
|
||||
},
|
||||
RELEASE_START,
|
||||
);
|
||||
|
||||
window.__timelines["press-scene"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
### Subtle press (status save / muted CTA)
|
||||
|
||||
Less compression, gentler overshoot, smaller burst. `PRESS_SCALE` toward the high end of its range (~0.96), `BOUNCE_FACTOR` toward the low end (~1.4), `BURST_PEAK_SCALE` and `BURST_PEAK_OPACITY` reduced.
|
||||
|
||||
### Dramatic press (hero CTA / "ship it" moment)
|
||||
|
||||
Deeper compression, more overshoot, larger burst. `PRESS_SCALE` toward the low end (~0.88), `BOUNCE_FACTOR` toward the high end (~2.5), `BURST_PEAK_SCALE` and `BURST_PEAK_OPACITY` maxed.
|
||||
|
||||
### Color shift during press
|
||||
|
||||
Darken the button mid-press, return on release. Same timeline positions as the scale tweens — interpolated `backgroundColor` on `#btn`. State continuity rule still applies: the release-color tween's start equals the press-color tween's end.
|
||||
|
||||
```js
|
||||
tl.to("#btn", { backgroundColor: "{btnPressedColor}", duration: PRESS_DUR }, PRESS_START);
|
||||
tl.to("#btn", { backgroundColor: "{btnRestColor}", duration: RELEASE_DUR }, RELEASE_START);
|
||||
```
|
||||
|
||||
### State change at release (approve / confirm pattern)
|
||||
|
||||
When the press signals confirmation, swap the button's resting color to a success token at `RELEASE_START` (instead of returning to `{btnRestColor}`), then pop a checkmark via a separate `back.out(${CHECK_BOUNCE})` tween at the same position. The button is now in its terminal state — no further presses expected.
|
||||
|
||||
```js
|
||||
tl.to("#btn", { backgroundColor: "{successColor}", duration: RELEASE_DUR }, RELEASE_START);
|
||||
tl.to(
|
||||
".btn-check",
|
||||
{ scale: 1, duration: CHECK_POP_DUR, ease: `back.out(${CHECK_BOUNCE})` },
|
||||
RELEASE_START,
|
||||
);
|
||||
```
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
### Geometry
|
||||
|
||||
- **BTN_WIDTH / BTN_HEIGHT** — button footprint.
|
||||
- Range: button area ≥ 3-5% of canvas (a 320×68 button at 1080p is ~1% and reads as visually insignificant)
|
||||
- Effects: smaller → press barely reads; larger → press dominates the frame
|
||||
- Constraints: `BTN_WIDTH × BTN_HEIGHT / (canvasW × canvasH) ≥ 0.03`
|
||||
- **BTN_RADIUS** — corner radius.
|
||||
- Range: `BTN_HEIGHT × 0.15` (sharp/modern) → `BTN_HEIGHT / 2` (pill)
|
||||
- **BTN_FONT_SIZE / BTN_LETTER_SPACING** — typographic weight.
|
||||
- Range: `BTN_FONT_SIZE ≈ BTN_HEIGHT × 0.4-0.5`; letter-spacing 4-10 px reads as "actionable label"
|
||||
|
||||
### Press dynamics
|
||||
|
||||
- **PRESS_SCALE** — compression depth.
|
||||
- Range: 0.88 (dramatic) → 0.92 (default) → 0.96 (subtle)
|
||||
- Effects: lower → more tactile / weightier; higher → barely-there acknowledgment
|
||||
- Constraints: never <0.85 (button feels broken) or >0.98 (no perceptible dip)
|
||||
- **PRESS_DUR** — compression duration.
|
||||
- Range: 0.10-0.30 s
|
||||
- Effects: shorter → snappier / "instant-feeling"; longer → slow squish
|
||||
- Constraints: shorter than `RELEASE_DUR` (input is faster than spring recovery)
|
||||
- **RELEASE_DUR** — spring recovery duration.
|
||||
- Range: 0.40-0.90 s
|
||||
- Effects: shorter → tight pop; longer → loose, wobbly settle
|
||||
- **BOUNCE_FACTOR** — `back.out(BOUNCE_FACTOR)` overshoot strength.
|
||||
- Range: 1.4 (soft) → 2.0 (firm pop) → 2.8 (cartoony)
|
||||
- Effects: low end barely overshoots; high end reads as cartoonish; tune by feel
|
||||
- Alternative: switch to `elastic.out(amplitude, period)` for a rubbery oscillation instead of a single overshoot
|
||||
- **PRESS_START / RELEASE_START** — timeline positions.
|
||||
- Constraints: `RELEASE_START = PRESS_START + PRESS_DUR` (state continuity — see Critical Constraints)
|
||||
|
||||
### Burst glow
|
||||
|
||||
- **BURST_PEAK_SCALE** — radial pop max scale.
|
||||
- Range: 3 (subtle) → 6 (default) → 8 (dramatic)
|
||||
- Constraints: ≤ ~8 — beyond that the radial gradient pixelates visibly
|
||||
- **BURST_PEAK_OPACITY** — burst max opacity.
|
||||
- Range: 0.4 (subtle) → 0.8 (default) → 1.0 (dramatic)
|
||||
- **BURST_GROW_DUR / BURST_FADE_DUR** — grow vs. fade timing.
|
||||
- Range: 0.4-0.7 s each; default grow ≈ fade
|
||||
- **BURST_BLUR** — gaussian blur on the burst layer.
|
||||
- Range: 40-100 px; smaller reads as a hard ring, larger as ambient haze
|
||||
|
||||
### Background glow
|
||||
|
||||
- **BG_GLOW_PEAK_OPACITY** — peak environmental glow.
|
||||
- Range: 0.1 (subtle) → 0.25 (default) → 0.45 (dramatic)
|
||||
- Constraints: ≤ 0.45 — higher washes the whole composition
|
||||
- **BG_GLOW_FADE_DUR** — fade-in duration.
|
||||
- Range: 0.6-1.0 s
|
||||
- **BG_GLOW_INSET** — negative inset so the radial extends past the stage edges.
|
||||
- Range: typically `-300` to `-500` px on a 1920×1080 canvas
|
||||
|
||||
### Optional "approve" variation
|
||||
|
||||
- **CHECK_BOUNCE** — checkmark pop overshoot.
|
||||
- Range: 1.4-2.0; firmer than the button's main `BOUNCE_FACTOR` to read as a punctuating "stamp"
|
||||
- **CHECK_POP_DUR** — checkmark scale-up duration.
|
||||
- Range: 0.3-0.6 s
|
||||
|
||||
### Tokens
|
||||
|
||||
- **{btnBg} / {btnRestColor} / {btnPressedColor}** — primary button surface; pressed darker than rest
|
||||
- **{btnRestShadow} / {btnPressedShadow}** — rest shadow is large + diffuse; pressed is small + tight (the button "sinks toward the surface")
|
||||
- **{burstGradient}** — radial; saturated near center, fading to transparent (color should be darker + more saturated than `{btnBg}` — same-color glow looks washed out)
|
||||
- **{bgGlowGradient}** — full-stage radial, low-opacity tint of `{btnBg}`'s hue family
|
||||
- **{successColor}** — confirmation green / brand-success for the approve variation
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **State continuity** — release start value MUST exactly match press end value. With a GSAP timeline, the first tween's end value automatically becomes the second tween's start when they target the same property at adjacent times.
|
||||
- **Visual weight** — button area should be **≥3-5% of canvas**. Smaller and the press reads as visually insignificant.
|
||||
- **Linear press, spring release** — the compression is `power1.in/out`, the recovery is `back.out`. Both spring → squishy; both linear → mechanical / no overshoot punch.
|
||||
- **Anchor compression on center** — `transform-origin: 50% 50%` (default). Otherwise the button collapses asymmetrically.
|
||||
- **Burst behind, not in front** — burst `z-index: 1`, button `z-index: 2`. If burst sits in front, it occludes the button at peak opacity.
|
||||
- **Glow color darker + more saturated than element** — bright surface → dark, saturated glow. Same-color glow looks washed out.
|
||||
- **Don't tween `boxShadow` and `filter` together on the same element** — they compete in the layout pipeline; pick one. Shadow on the button, blur on a separate burst layer.
|
||||
- **Climax beats need dwell time** — after the burst peak + label/wordmark reveal, the composition must run for **≥1s more** (≥2s for "dramatic" variants) before ending. A reveal at `t=DURATION−0.2s` reads as "flashed and gone."
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Timeline must be paused**: `gsap.timeline({ paused: true })`
|
||||
- **Registry key = `data-composition-id`**
|
||||
- **No CSS `transition`** on the button — those interpolate independently of HF seek and cause flicker
|
||||
- **`will-change: transform`** if the button compounds with other animation layers
|
||||
- **`RELEASE_START = PRESS_START + PRESS_DUR`** — adjacency on the same property is what makes state continuity automatic; gap or overlap breaks it
|
||||
- **Burst max scale ≤ ~8** — beyond that the radial gradient pixelates visibly
|
||||
- **Background glow `opacity ≤ 0.45`** — higher and it washes the whole composition
|
||||
- **GSAP transform aliases only**: `x`, `y`, `scale`, `rotation`. Never tween `width` / `height` / `left` / `top`.
|
||||
|
||||
## Combinations
|
||||
|
||||
- [sine-wave-loop.md](sine-wave-loop.md) — idle micro-float on the button BEFORE the press (slight breathing, sells "ready")
|
||||
- [center-outward-expansion.md](center-outward-expansion.md) — burst of badges outward synced to the press release
|
||||
- [cursor-click-ripple.md](cursor-click-ripple.md) — cursor click that triggers the press
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — `back.out` ease + multi-tween coordination
|
||||
- `/hyperframes-core` — composition wiring
|
||||
- `/hyperframes-cli` — `hyperframes lint`
|
||||
@@ -0,0 +1,277 @@
|
||||
---
|
||||
name: reactive-displacement
|
||||
description: Physical collision where an entering element's spring drives the exiting element's displacement — single source of truth makes the motion causally linked.
|
||||
metadata:
|
||||
tags: transition, physics, collision, displacement, spring, causal
|
||||
---
|
||||
|
||||
# Reactive Displacement
|
||||
|
||||
Exit animation of element A is mathematically DERIVED from the entry spring of element B. Creates a causal link: "A moves _because_ B hit it." Distinct from [scale-swap-transition](scale-swap-transition.md) (which overlaps but isn't causal) and [card-morph-anchor](card-morph-anchor.md) (which uses one container morphing dimensions).
|
||||
|
||||
## How It Works
|
||||
|
||||
A single 0→1 driver tween (the "entry spring") feeds two derived motions:
|
||||
|
||||
- **Intruder** (B, entering): position interpolated from off-stage to settled
|
||||
- **Victim** (A, exiting): position interpolated from settled to off-stage in the OPPOSITE direction, but completing at a fraction `VICTIM_FRACTION` of the driver (not 1.0)
|
||||
|
||||
The fact that the victim's exit finishes BEFORE the intruder's entry creates the "hit then settle" rhythm. Both motions share the same eased driver, so the impact moment is mathematically synchronized.
|
||||
|
||||
## HTML
|
||||
|
||||
```html
|
||||
<div
|
||||
class="scene"
|
||||
id="collide-scene"
|
||||
data-composition-id="collide-scene"
|
||||
data-start="0"
|
||||
data-duration="3"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div class="stage">
|
||||
<div class="card victim" id="victim">
|
||||
<div class="card-title">{victimHeadline}</div>
|
||||
<div class="card-sub">{victimSubline}</div>
|
||||
</div>
|
||||
<div class="card intruder" id="intruder">
|
||||
<div class="card-title">{intruderHeadline}</div>
|
||||
<div class="card-sub">{intruderSubline}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS
|
||||
|
||||
```css
|
||||
.scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: radial-gradient(ellipse at center, {bgColor} 0%, {bgColorDeep} 70%);
|
||||
font-family: {font};
|
||||
}
|
||||
.stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.card {
|
||||
position: absolute;
|
||||
/* both at center; transform translates them */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
padding: 64px 80px;
|
||||
border-radius: 28px;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
.victim {
|
||||
background: linear-gradient(160deg, {victimTint} 0%, {bgColorDeep} 70%);
|
||||
border: 1px solid {victimTint};
|
||||
z-index: 1;
|
||||
}
|
||||
.intruder {
|
||||
background: linear-gradient(160deg, {intruderTint} 0%, {bgColorDeep} 70%);
|
||||
border: 2px solid {intruderBorder};
|
||||
box-shadow: 0 28px 96px {intruderTint};
|
||||
z-index: 2;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 200px;
|
||||
font-weight: 900;
|
||||
color: {textColor};
|
||||
line-height: 1;
|
||||
letter-spacing: -4px;
|
||||
}
|
||||
.card-sub {
|
||||
font-size: 36px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 10px;
|
||||
text-transform: uppercase;
|
||||
color: {accentColor};
|
||||
text-align: center;
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// Off-stage distances are derived from the stage width.
|
||||
const INTRUDER_START_X = STAGE_W; // off-stage right
|
||||
const VICTIM_END_X = -STAGE_W; // off-stage left, opposite direction
|
||||
|
||||
// Initial state — victim centered, intruder off-stage right
|
||||
gsap.set("#victim", { x: 0, opacity: 1, rotation: 0 });
|
||||
gsap.set("#intruder", { x: INTRUDER_START_X, opacity: 0, rotation: -INTRUDER_TILT });
|
||||
|
||||
// Single driver — the entry spring — runs 0→1 over the impact arc
|
||||
const driver = { p: 0 };
|
||||
tl.to(
|
||||
driver,
|
||||
{
|
||||
p: 1,
|
||||
duration: DRIVER_DUR,
|
||||
ease: `back.out(${BOUNCE_FACTOR})`, // intruder spring
|
||||
onUpdate: () => {
|
||||
// Intruder: full 0→1 progress maps to enter (off-stage → center)
|
||||
const intruderX = INTRUDER_START_X * (1 - driver.p);
|
||||
const intruderOpacity = Math.min(1, driver.p * FADE_IN_SHARPNESS);
|
||||
const intruderRot = -INTRUDER_TILT * (1 - driver.p); // settle to 0°
|
||||
const intruder = document.getElementById("intruder");
|
||||
intruder.style.transform = `translate(-50%, -50%) translateX(${intruderX}px) rotate(${intruderRot}deg)`;
|
||||
intruder.style.opacity = String(intruderOpacity);
|
||||
|
||||
// Victim: completes exit at VICTIM_FRACTION of driver (intruder still flying in)
|
||||
// so the impact MOMENT is the visual punch — by the time intruder centers,
|
||||
// victim is already off-stage.
|
||||
const victimP = Math.min(1, driver.p / VICTIM_FRACTION);
|
||||
const victimX = VICTIM_END_X * victimP;
|
||||
const victimOpacity = 1 - victimP;
|
||||
const victim = document.getElementById("victim");
|
||||
victim.style.transform = `translate(-50%, -50%) translateX(${victimX}px)`;
|
||||
victim.style.opacity = String(victimOpacity);
|
||||
},
|
||||
},
|
||||
DRIVER_AT,
|
||||
);
|
||||
|
||||
// Climax dwell — intruder holds at center after settle (no additional motion;
|
||||
// composition continues with intruder centered for ≥ DWELL_MIN seconds).
|
||||
|
||||
window.__timelines["collide-scene"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
- **DRIVER_AT** — when the entry spring begins
|
||||
- Range: phase-dependent (typically a few seconds in)
|
||||
- Effects: too early skips setup beats; too late stalls the cut
|
||||
- Constraints: must allow ≥ DWELL_MIN of climax dwell before composition ends
|
||||
- Reference: example schedules the displacement after the prior reading beat resolves
|
||||
|
||||
- **DRIVER_DUR** — full intruder entry duration
|
||||
- Range: 0.6-1.4 s
|
||||
- Effects: short = zippy/punchy impact; long = heavy/landed impact
|
||||
- Constraints: tune against `BOUNCE_FACTOR` — higher bounce on long durations reads as floaty
|
||||
- Reference: see the corresponding blueprint / example
|
||||
|
||||
- **BOUNCE_FACTOR** — `back.out()` coefficient on the intruder spring
|
||||
- Range: 1.2-2.0 (discrete choice within `back.out` family)
|
||||
- Effects: low ≈ firm settle; high ≈ overshoot/bounce
|
||||
- Constraints: ease family stays `back.out` (or upgrade to `elastic.out` if you want oscillation); changing family rewrites the feel
|
||||
- Reference: examples typically sit between 1.4 and 1.6
|
||||
|
||||
- **VICTIM_FRACTION** — fraction of `DRIVER_DUR` over which the victim completes its exit
|
||||
- Range: 0.4-0.5
|
||||
- Effects: < 0.4 victim disappears before impact reads; > 0.5 motion feels parallel, not causal
|
||||
- Constraints: hard upper limit ~0.6; beyond that the collision metaphor breaks
|
||||
- Reference: this rule's pattern uses ~0.5
|
||||
|
||||
- **STAGE_W** — stage width in pixels, used to place elements off-stage
|
||||
- Range: equal to the composition's `data-width`
|
||||
- Effects: smaller values leave the off-stage element partially visible at start
|
||||
- Constraints: must be ≥ composition width
|
||||
- Reference: examples use the project's render width directly
|
||||
|
||||
- **INTRUDER_TILT** — initial rotation (degrees) the intruder rotates from as it settles to 0°
|
||||
- Range: 5-15°
|
||||
- Effects: low = clean glide; high = visible "spin-and-plant"
|
||||
- Constraints: keep sign consistent with entry direction (matches momentum transfer)
|
||||
- Reference: ~10° is a typical mid-impact tilt
|
||||
|
||||
- **FADE_IN_SHARPNESS** — multiplier controlling how quickly intruder opacity reaches 1
|
||||
- Range: 3-8 (intruder reaches opacity 1 at `1/FADE_IN_SHARPNESS` of progress)
|
||||
- Effects: low = soft fade alongside motion; high = pops in early and reads as solid
|
||||
- Constraints: > 1; below 1 means intruder is still transparent at center
|
||||
- Reference: most examples use a sharp early reveal
|
||||
|
||||
- **DWELL_MIN** — minimum climax dwell after the intruder settles
|
||||
- Range: ≥ 1.0 s
|
||||
- Effects: shorter feels rushed and unreadable; longer stalls the comp
|
||||
- Constraints: post-impact dwell is where the new content gets read — do not skip
|
||||
- Reference: 1.0-1.5 s is typical
|
||||
|
||||
## Variations
|
||||
|
||||
### Impact rotation on victim
|
||||
|
||||
The victim doesn't just slide off — it ALSO rotates from the impact angle:
|
||||
|
||||
```js
|
||||
const victimRot = victimP * -VICTIM_KICK_DEG; // rotates as it slides
|
||||
victim.style.transform = `translate(-50%, -50%) translateX(${victimX}px) rotate(${victimRot}deg)`;
|
||||
```
|
||||
|
||||
`VICTIM_KICK_DEG` is typically 15-25°; pick magnitude to match the perceived intruder weight.
|
||||
|
||||
### Vertical collision
|
||||
|
||||
Intruder enters from top, victim displaced downward. Same math with Y instead of X. Visual feels like "weight dropped on it."
|
||||
|
||||
### Wobble after settle
|
||||
|
||||
After the intruder centers, a damped sine wobble (`±WOBBLE_AMP_DEG` rotation, decaying over `WOBBLE_DUR`) before stillness. Adds "impact aftermath" before climax dwell.
|
||||
|
||||
```js
|
||||
const wobble = { p: 0 };
|
||||
tl.to(
|
||||
wobble,
|
||||
{
|
||||
p: Math.PI * WOBBLE_CYCLES * 2,
|
||||
duration: WOBBLE_DUR,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
const rot =
|
||||
Math.sin(wobble.p) * WOBBLE_AMP_DEG * (1 - wobble.p / (Math.PI * WOBBLE_CYCLES * 2)); // linear decay
|
||||
intruder.style.transform = `translate(-50%, -50%) rotate(${rot}deg)`;
|
||||
},
|
||||
},
|
||||
DRIVER_AT + DRIVER_DUR,
|
||||
);
|
||||
```
|
||||
|
||||
### Multi-victim ripple
|
||||
|
||||
Intruder displaces multiple aligned cards, each victim getting a slightly delayed exit (cascade ripple). Each victim's `victimP` uses a different driver phase offset.
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Single driver = single source of truth** — the entry spring drives BOTH motions. Independent tweens for intruder and victim destroy the causal link; they'd just happen to be near each other in time, not collided.
|
||||
- **Victim completes at a fraction of driver** — by the time the intruder reaches center, the victim is GONE. The "hit" is the moment they overlap; after that the victim is just exiting space the intruder will fill.
|
||||
- **Directional momentum transfer** — intruder from positive X → victim moves negative X. Same axis. If they move on different axes, it looks like they passed each other, not collided.
|
||||
- **Intruder z-index ABOVE victim** — during overlap, the intruder should appear in FRONT (it's the "winner" of the collision). Otherwise the victim looks like it tunneled through.
|
||||
- **Intruder enters with rotation, settles flat** — adds momentum visualization. A small initial tilt → 0° at settle reads as "spinning in then planting."
|
||||
- **Climax dwell after impact** — the impact is the headline beat. Post-impact dwell is where the new content gets read.
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Timeline must be paused**: `gsap.timeline({ paused: true })`
|
||||
- **Registry key = `data-composition-id`**
|
||||
- **Single driver, multiple derived values in same onUpdate** — don't tween intruder and victim with separate `tl.to()` calls; use ONE driver and compute both inside its onUpdate
|
||||
- **`overflow: hidden` on `.scene`** — off-stage motion exceeds the frame
|
||||
- **`will-change: transform, opacity`** on both cards
|
||||
- **Intruder z-index > victim z-index** — explicit, not relying on DOM order alone
|
||||
|
||||
## Combinations
|
||||
|
||||
- [hacker-flip-3d.md](hacker-flip-3d.md) — intruder text reveals via hacker-flip during the entry phase
|
||||
- [sine-wave-loop.md](sine-wave-loop.md) — idle breathing on intruder during climax dwell
|
||||
- [vertical-spring-ticker.md](vertical-spring-ticker.md) — intruder is a ticker that "shoves" the previous content out
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — single driver, multi-value onUpdate
|
||||
- `/hyperframes-core` — composition wiring
|
||||
- `/hyperframes-cli` — `hyperframes lint`
|
||||
@@ -0,0 +1,298 @@
|
||||
---
|
||||
name: scale-swap-transition
|
||||
description: Coordinated shrink-out + spring pop-in morph-like transition between two elements — no SVG path interpolation needed.
|
||||
metadata:
|
||||
tags: transition, morph, scale, swap, spring, pop
|
||||
---
|
||||
|
||||
# Scale-Swap Transition
|
||||
|
||||
Simulates a "morph" between two DOM elements by overlapping exit and entrance scale animations. Lighter weight than [card-morph-anchor](card-morph-anchor.md) (which morphs container dimensions) and easier than SVG path interpolation.
|
||||
|
||||
## How It Works
|
||||
|
||||
At a single trigger time, two coordinated tweens fire:
|
||||
|
||||
1. **Outgoing element**: scale `1.0 → EXIT_SCALE` + opacity `1 → 0` (fast `power2.in`)
|
||||
2. **Incoming element**: scale `EXIT_SCALE → 1.0` + opacity `0 → 1` (bouncy `back.out(${BOUNCE_FACTOR})` with overshoot)
|
||||
|
||||
A small `OVERLAP` window during which both are mid-tween creates the "morph" illusion. Incoming sits on top via z-index so the outgoing's fade-tail doesn't bleed through.
|
||||
|
||||
## HTML
|
||||
|
||||
```html
|
||||
<div
|
||||
class="scene"
|
||||
id="swap-scene"
|
||||
data-composition-id="swap-scene"
|
||||
data-start="0"
|
||||
data-duration="3"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div class="stack">
|
||||
<div class="swap-wrap">
|
||||
<div class="card outgoing" id="outgoing">
|
||||
<div class="icon">{outgoingIcon}</div>
|
||||
<div class="title">{outgoingLabel}</div>
|
||||
</div>
|
||||
<div class="card incoming" id="incoming">
|
||||
<div class="icon">{incomingIcon}</div>
|
||||
<div class="title">{incomingLabel}</div>
|
||||
<div class="sub" id="sub">{incomingSubline}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="brand">{Brand}</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS
|
||||
|
||||
```css
|
||||
.scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: {sceneBg};
|
||||
font-family: {font};
|
||||
}
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: STACK_GAP;
|
||||
}
|
||||
.swap-wrap {
|
||||
position: relative;
|
||||
width: SWAP_WRAP_W;
|
||||
height: SWAP_WRAP_H;
|
||||
}
|
||||
.card {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: CARD_INNER_GAP;
|
||||
border-radius: CARD_RADIUS;
|
||||
padding: CARD_PADDING;
|
||||
/* Both elements share transform-origin so they "morph" around the same anchor */
|
||||
transform-origin: 50% 50%;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
.card .icon {
|
||||
font-size: ICON_SIZE;
|
||||
}
|
||||
.card .title {
|
||||
font-size: TITLE_SIZE;
|
||||
font-weight: 900;
|
||||
letter-spacing: TITLE_TRACKING;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.card .sub {
|
||||
font-size: SUB_SIZE;
|
||||
font-weight: 700;
|
||||
color: {accentColor};
|
||||
opacity: 0;
|
||||
}
|
||||
.outgoing {
|
||||
z-index: 1;
|
||||
background: {outgoingBg};
|
||||
border: 1px solid {outgoingBorder};
|
||||
color: {textColor};
|
||||
}
|
||||
.incoming {
|
||||
/* Incoming starts hidden + smaller, will pop in */
|
||||
z-index: 2;
|
||||
background: {incomingBg};
|
||||
border: 1px solid {incomingBorder};
|
||||
color: {textColor};
|
||||
opacity: 0;
|
||||
transform: scale(EXIT_SCALE);
|
||||
}
|
||||
.brand {
|
||||
font-size: BRAND_SIZE;
|
||||
font-weight: 900;
|
||||
letter-spacing: BRAND_TRACKING;
|
||||
text-transform: uppercase;
|
||||
color: {brandColor};
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// Outgoing: shrink + fade fast
|
||||
tl.to(
|
||||
"#outgoing",
|
||||
{
|
||||
scale: EXIT_SCALE,
|
||||
opacity: 0,
|
||||
duration: EXIT_DUR,
|
||||
ease: "power2.in",
|
||||
},
|
||||
TRIGGER,
|
||||
);
|
||||
|
||||
// Incoming: scale up + fade in with overshoot, starts slightly BEFORE outgoing
|
||||
// finishes (OVERLAP creates the morph illusion).
|
||||
tl.to(
|
||||
"#incoming",
|
||||
{
|
||||
scale: 1.0,
|
||||
opacity: 1,
|
||||
duration: ENTER_DUR,
|
||||
ease: `back.out(${BOUNCE_FACTOR})`,
|
||||
},
|
||||
TRIGGER + EXIT_DUR - OVERLAP,
|
||||
);
|
||||
|
||||
// Subline reveals AFTER the incoming card settles
|
||||
tl.fromTo(
|
||||
"#sub",
|
||||
{ opacity: 0, y: SUB_REVEAL_Y_PX },
|
||||
{ opacity: 1, y: 0, duration: SUB_REVEAL_DUR, ease: "power3.out" },
|
||||
TRIGGER + EXIT_DUR + SUB_REVEAL_DELAY,
|
||||
);
|
||||
|
||||
// Brand fades in early for context
|
||||
tl.from(
|
||||
".brand",
|
||||
{ opacity: 0, y: BRAND_REVEAL_Y_PX, duration: BRAND_REVEAL_DUR, ease: "power3.out" },
|
||||
BRAND_REVEAL_AT,
|
||||
);
|
||||
|
||||
window.__timelines["swap-scene"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
### Delayed inner content reveal
|
||||
|
||||
The classic pattern: morph the container, then reveal inner text once the container has settled (as in the example above with `.sub`). The 0.2-0.4s gap between morph end and content reveal lets the viewer's eye land on the new container shape before reading the content.
|
||||
|
||||
### Triple swap (3-state cycle)
|
||||
|
||||
Chain: A→B→C with two triggers `TRIGGER_AB` and `TRIGGER_BC`. Each transition needs its own pair of tweens, and the previous incoming becomes the next outgoing. Useful for state evolution narratives (e.g. early-state → mid-state → final-state labels).
|
||||
|
||||
```js
|
||||
tl.to("#stateA", { scale: EXIT_SCALE, opacity: 0, duration: EXIT_DUR }, TRIGGER_AB);
|
||||
tl.to(
|
||||
"#stateB",
|
||||
{ scale: 1.0, opacity: 1, duration: ENTER_DUR, ease: `back.out(${BOUNCE_FACTOR})` },
|
||||
TRIGGER_AB + EXIT_DUR - OVERLAP,
|
||||
);
|
||||
tl.to("#stateB", { scale: EXIT_SCALE, opacity: 0, duration: EXIT_DUR }, TRIGGER_BC);
|
||||
tl.to(
|
||||
"#stateC",
|
||||
{ scale: 1.0, opacity: 1, duration: ENTER_DUR, ease: `back.out(${BOUNCE_FACTOR})` },
|
||||
TRIGGER_BC + EXIT_DUR - OVERLAP,
|
||||
);
|
||||
```
|
||||
|
||||
### Color-shift transition (no scale)
|
||||
|
||||
For a flat morph between two same-shape states, drop the scale and keep only opacity + a brief background hue tween. Less dramatic but matches a more product-UI tone.
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
### Timing (seconds)
|
||||
|
||||
- **TRIGGER** — when the swap fires.
|
||||
- Constraints: must be ≥ the outgoing element's settled time + a presence-dwell so the outgoing "lands" before transforming
|
||||
- **EXIT_DUR** — outgoing shrink + fade duration.
|
||||
- Range: 0.3-0.5 s
|
||||
- **ENTER_DUR** — incoming pop-in duration.
|
||||
- Range: 0.45-0.7 s (longer than `EXIT_DUR` to let the overshoot settle)
|
||||
- **OVERLAP** — how much the entrance starts before the exit finishes.
|
||||
- Range: 0.1-0.2 s
|
||||
- Constraints: too much (>0.3 s) makes both clearly visible together (no morph); too little (<0.05 s) leaves a visible empty gap
|
||||
- **SUB_REVEAL_DELAY** — gap between incoming settle and subline reveal.
|
||||
- Range: 0.2-0.4 s; reveals during the morph compete with the swap for attention
|
||||
- **SUB_REVEAL_DUR** — subline fade-in.
|
||||
- Range: 0.3-0.5 s
|
||||
- **BRAND_REVEAL_AT** — when the brand/context line fades in.
|
||||
- Constraints: must be < `TRIGGER` (brand is context for the swap, not synchronous with it)
|
||||
- **BRAND_REVEAL_DUR** — brand fade-in duration.
|
||||
- Range: 0.4-0.8 s
|
||||
|
||||
### Physics
|
||||
|
||||
- **EXIT_SCALE** — target scale for outgoing (and starting scale for incoming).
|
||||
- Range: 0.6-0.8; smaller exits feel more dramatic but risk reading as "vanish" instead of "morph"
|
||||
- **BOUNCE_FACTOR** — `back.out(${BOUNCE_FACTOR})` overshoot on the incoming.
|
||||
- Range: 1.4 (soft) - 1.8 (firm) - 2.2 (cartoony)
|
||||
|
||||
### Positioning offsets
|
||||
|
||||
- **SUB_REVEAL_Y_PX** — subline initial y offset (positive = below resting).
|
||||
- Range: 8-20 px
|
||||
- **BRAND_REVEAL_Y_PX** — brand initial y offset.
|
||||
- Range: 10-24 px
|
||||
|
||||
### Layout
|
||||
|
||||
- **STACK_GAP** — gap between swap container and brand line.
|
||||
- Range: 40-96 px
|
||||
- **SWAP_WRAP_W / SWAP_WRAP_H** — fixed swap container dimensions; both cards `inset: 0` inside.
|
||||
- Constraints: pick dimensions that fit both states' content; the wrap does not resize during the swap
|
||||
- **CARD_INNER_GAP** — gap between icon and title inside a card.
|
||||
- Range: 16-32 px
|
||||
- **CARD_RADIUS / CARD_PADDING** — card corner radius and inner padding.
|
||||
- Range: radius 24-40 px; padding 32-64 px
|
||||
- **ICON_SIZE / TITLE_SIZE / SUB_SIZE / BRAND_SIZE** — typographic sizes.
|
||||
- Constraints: titles dominate (~80-120 px at 1080p); sub and brand are accent-sized
|
||||
- **TITLE_TRACKING / BRAND_TRACKING** — letter-spacing on uppercase labels.
|
||||
- Range: 4-16 px (uppercase reads better with positive tracking)
|
||||
|
||||
### Tokens
|
||||
|
||||
- **{sceneBg}** — background gradient/color
|
||||
- **{font}** — typographic stack
|
||||
- **{textColor}** / **{accentColor}** / **{brandColor}** — semantic color tokens
|
||||
- **{outgoingBg}** / **{outgoingBorder}** — outgoing card surface + border (typically warm or pre-action hue)
|
||||
- **{incomingBg}** / **{incomingBorder}** — incoming card surface + border (typically cool or post-action hue)
|
||||
- **{outgoingIcon}** / **{incomingIcon}** — single glyph/emoji per state
|
||||
- **{outgoingLabel}** / **{incomingLabel}** — state labels
|
||||
- **{incomingSubline}** — supporting copy that fades in after the incoming settles
|
||||
- **{Brand}** — brand line shown beneath the swap
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Incoming z-index ABOVE outgoing** — without this, the outgoing's fade-tail (opacity 0.3-0.5) bleeds through the incoming's lower opacity and creates a "double-exposed" muddy frame
|
||||
- **Both elements share `transform-origin: 50% 50%`** — different origins make the morph feel like one thing teleporting somewhere else
|
||||
- **`OVERLAP` in the 0.1-0.2 s window** — too much overlap and both are clearly visible together (no morph); too little and there's a visible empty gap
|
||||
- **Bouncy ease ONLY for the incoming** — outgoing uses `power2.in` (rushing away), incoming uses `back.out(${BOUNCE_FACTOR})` (arriving with weight). Reverse it and the swap feels mechanical
|
||||
- **Inner content reveals AFTER container settles** — see `SUB_REVEAL_DELAY`. Reveals during the morph compete for attention and lose
|
||||
- **Climax dwell ≥1 s after final state lands** — see SKILL universal constraints. After incoming + subline both settle, hold for ≥1 s
|
||||
- **Brand reveal early, not at the swap** — context (brand, eyebrow) sets the stage; the swap is the headline. If brand reveals AT the swap, it competes
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Timeline must be paused**: `gsap.timeline({ paused: true })`
|
||||
- **Registry key = `data-composition-id`**
|
||||
- **No CSS `transition`** on either swap element — competes with GSAP
|
||||
- **`will-change: transform, opacity`** on both swap elements
|
||||
- **Both elements use `position: absolute; inset: 0`** in the same wrapper — they occupy the same footprint, swap fades one out and pops one in
|
||||
- **Don't `display: none` the outgoing** after fade — leave it at `opacity: 0` so layout doesn't reflow
|
||||
|
||||
## Combinations
|
||||
|
||||
- [press-release-spring.md](press-release-spring.md) — button press TRIGGERS the swap (cause and effect)
|
||||
- [sine-wave-loop.md](sine-wave-loop.md) — idle breathing on the final state
|
||||
- [card-morph-anchor.md](card-morph-anchor.md) — alternative for SHAPE-changing transitions (this rule is for SAME-shape state swaps)
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — two coordinated tweens with overlap
|
||||
- `/hyperframes-core` — composition wiring
|
||||
- `/hyperframes-cli` — `hyperframes lint`
|
||||
@@ -0,0 +1,278 @@
|
||||
---
|
||||
name: sine-wave-loop
|
||||
description: Bounded sine-driven idle — subtle jitter or a single genuinely-needed bounded ambient breath on a held element. De-emphasized: circular breathing as "aliveness" is cheap; prefer sequential reveal timed to the VO, then subtle jitter, before reaching here.
|
||||
metadata:
|
||||
tags: idle, jitter, bounded-ambient, sine, trigonometry, low-amplitude, post-entry
|
||||
---
|
||||
|
||||
# Sine Wave Loop (subtle jitter / bounded ambient)
|
||||
|
||||
> **Reach for this last.** Per the motion doctrine (`references/motion-language.md`): **circular breathing — scaling text/cards up and down to look "alive" — is cheap, the agent's reflexive cheat, and reads weak.** "I'd rather have NO motion than BAD motion." Before using this rule to keep a held frame alive, prefer (1) **sequential reveal timed to the voiceover** — reveal the next line/element when the VO says it, across the back ~50% of the scene; that, not ambient motion, is what fills a shot. If a frame has genuinely settled and still needs a touch of life, the **sanctioned move is subtle jitter** — a small, low-amplitude jitter (this rule, at the LOW end of its amplitude range). A full breathing loop is reserved for the rare case where a single held hero genuinely needs bounded ambient; keep it de-emphasized and small.
|
||||
|
||||
Keeps a settled element from feeling dead — as **subtle jitter** or, rarely, a single bounded ambient breath — using `Math.sin` driven by a finite timeline tween. This is the implementation behind the "subtle jitter" move in the motion vocabulary; it is **not** a license to breathe every hero.
|
||||
|
||||
## How It Works
|
||||
|
||||
A long tween advances a `phase` value from 0 → 2π (or 0 → some multiple thereof). On every onUpdate, the phase feeds into `Math.sin()` to produce a small periodic offset added to the element's transform (`scale`, `translateY`, `rotate`).
|
||||
|
||||
The trick to a "no jump" transition from entry to idle: at `phase = 0`, `sin(0) = 0` — the offset is zero, so the element starts at its post-entry resting state.
|
||||
|
||||
## HTML
|
||||
|
||||
```html
|
||||
<div
|
||||
class="scene"
|
||||
id="idle-scene"
|
||||
data-composition-id="idle-scene"
|
||||
data-start="0"
|
||||
data-duration="6"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div class="stack">
|
||||
<div class="hero" id="hero">{HeroLabel}</div>
|
||||
<div class="dot" id="dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS
|
||||
|
||||
```css
|
||||
.scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: {bgGradient};
|
||||
}
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: STACK_GAP;
|
||||
}
|
||||
.hero {
|
||||
font-family: {font};
|
||||
font-weight: 900;
|
||||
font-size: HERO_FONT_SIZE;
|
||||
letter-spacing: HERO_LETTER_SPACING;
|
||||
color: {textColor};
|
||||
text-transform: uppercase;
|
||||
/* Element gets its post-entry resting transform; idle only ADDS to it */
|
||||
will-change: transform;
|
||||
}
|
||||
.dot {
|
||||
width: DOT_SIZE;
|
||||
height: DOT_SIZE;
|
||||
border-radius: 50%;
|
||||
background: {accentColor};
|
||||
box-shadow: {accentGlow};
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
const hero = document.getElementById("hero");
|
||||
const dot = document.getElementById("dot");
|
||||
|
||||
// Phase 1 — entry beat (e.g. headline fade-up)
|
||||
tl.fromTo(
|
||||
hero,
|
||||
{ opacity: 0, y: ENTRY_Y, scale: ENTRY_SCALE },
|
||||
{ opacity: 1, y: 0, scale: 1, duration: ENTRY_DUR, ease: "power3.out" },
|
||||
0,
|
||||
);
|
||||
tl.fromTo(
|
||||
dot,
|
||||
{ opacity: 0, scale: 0 },
|
||||
{ opacity: 1, scale: 1, duration: DOT_ENTRY_DUR, ease: `back.out(${BOUNCE_FACTOR})` },
|
||||
DOT_ENTRY_START,
|
||||
);
|
||||
|
||||
// Phase 2 — idle breathing. Starts at IDLE_START_TIME AFTER entry settles.
|
||||
// Drive a phase 0 → 2π * CYCLES via a single tween, write sin() into transforms.
|
||||
|
||||
const phase = { p: 0 };
|
||||
tl.to(
|
||||
phase,
|
||||
{
|
||||
p: Math.PI * 2 * CYCLES,
|
||||
duration: IDLE_DUR,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
// Hero: scale breathes ±SCALE_AMP, y bobs ±Y_AMP_PX
|
||||
const scale = 1 + Math.sin(phase.p) * SCALE_AMP;
|
||||
const y = Math.sin(phase.p) * Y_AMP_PX;
|
||||
hero.style.transform = `translateY(${y}px) scale(${scale})`;
|
||||
|
||||
// Dot: out-of-phase scale (offset by π/2) — feels alive vs synced
|
||||
const dotScale = 1 + Math.sin(phase.p + Math.PI / 2) * DOT_SCALE_AMP;
|
||||
dot.style.transform = `scale(${dotScale})`;
|
||||
},
|
||||
},
|
||||
IDLE_START_TIME,
|
||||
);
|
||||
|
||||
window.__timelines["idle-scene"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
### Multiple offset frequencies (organic multi-octave breathing)
|
||||
|
||||
Combining frequencies feels more alive than pure sine:
|
||||
|
||||
```js
|
||||
const primary = Math.sin(phase.p) * SCALE_AMP_PRIMARY;
|
||||
const secondary = Math.sin(phase.p * OCTAVE_RATIO) * SCALE_AMP_SECONDARY; // higher-frequency overlay
|
||||
const scale = 1 + primary + secondary;
|
||||
```
|
||||
|
||||
### Conditional activation (only after entry settles)
|
||||
|
||||
If entry is interactive or skippable, gate the idle:
|
||||
|
||||
```js
|
||||
const idleActive = entryProgress >= GATE_THRESHOLD;
|
||||
const scale = idleActive ? 1 + Math.sin((time - IDLE_START_TIME) / PERIOD) * SCALE_AMP : 1;
|
||||
```
|
||||
|
||||
### Settle and fade (long-idle gate — strongly recommended when `IDLE_DUR > 6s`)
|
||||
|
||||
Drive amplitude through an envelope that fades to zero over the last ~20% of idle, so the scene visibly settles before the inter-scene transition lands:
|
||||
|
||||
```js
|
||||
const phase = { p: 0 };
|
||||
const FADE_FRAC = 0.2; // last 20% of idle = amplitude ramps to 0
|
||||
tl.to(
|
||||
phase,
|
||||
{
|
||||
p: Math.PI * 2 * CYCLES,
|
||||
duration: IDLE_DUR,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
const t = phase.p / (Math.PI * 2 * CYCLES); // 0 → 1 across idle
|
||||
const env = t < 1 - FADE_FRAC ? 1 : (1 - t) / FADE_FRAC; // 1 → 0 in tail
|
||||
const scale = 1 + Math.sin(phase.p) * SCALE_AMP * env;
|
||||
const y = Math.sin(phase.p) * Y_AMP_PX * env;
|
||||
hero.style.transform = `translateY(${y}px) scale(${scale})`;
|
||||
},
|
||||
},
|
||||
IDLE_START_TIME,
|
||||
);
|
||||
```
|
||||
|
||||
The element is in motion for the first 80% of idle, then comes to rest in the last 20%. Pairs naturally with break-boundary Tier-B transitions (the outgoing visual is static when the crossfade/push begins).
|
||||
|
||||
### Period vs cycle math
|
||||
|
||||
For an exact cycle of N seconds:
|
||||
|
||||
```js
|
||||
const divisor = (idleDurationSec * fps) / (Math.PI * 2);
|
||||
const value = Math.sin(frame / divisor) * amplitude;
|
||||
```
|
||||
|
||||
For HF (`onUpdate` doesn't expose frame directly), use the tween's `phase` value: drive `p: Math.PI * 2 * cyclesWanted` over `duration: idleDurationSec`.
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
### Layout / typography
|
||||
|
||||
- **STACK_GAP** — vertical gap between hero and dot.
|
||||
- Range: 0.2-0.4× `HERO_FONT_SIZE`
|
||||
- **HERO_FONT_SIZE / HERO_LETTER_SPACING** — typographic emphasis.
|
||||
- Range: 100-240 px for full-bleed compositions; spacing 0.04-0.06em
|
||||
- **DOT_SIZE** — accent indicator size.
|
||||
- Range: ~0.15-0.25× `HERO_FONT_SIZE` so the dot reads as accent, not a peer
|
||||
- **{accentGlow}** — `box-shadow` halo on the dot; typically `0 0 (DOT_SIZE) rgba(accentColor, 0.5-0.7)`
|
||||
|
||||
### Entry phase
|
||||
|
||||
- **ENTRY_Y / ENTRY_SCALE** — initial state before fade-up.
|
||||
- Range: `ENTRY_Y` 16-32 px (subtle rise), `ENTRY_SCALE` 0.94-0.98 (subtle inflation)
|
||||
- **ENTRY_DUR** — hero fade-up duration.
|
||||
- Range: 0.6-1.2s; bigger heroes want a longer settle
|
||||
- **DOT_ENTRY_START** — when the dot pops in relative to hero.
|
||||
- Constraints: typically `≈ 0.4-0.6× ENTRY_DUR` so the dot lands while the hero is still settling, not after
|
||||
- **DOT_ENTRY_DUR** — dot back-out pop duration.
|
||||
- Range: 0.4-0.7s
|
||||
- **BOUNCE_FACTOR** — `back.out(BOUNCE_FACTOR)` overshoot strength on the dot pop.
|
||||
- Range: 1.4 (soft) → 2.0 (firm) → 2.8 (cartoony)
|
||||
|
||||
### Idle phase
|
||||
|
||||
- **IDLE_START_TIME** — when breathing begins.
|
||||
- Constraints: `≥ ENTRY_DUR + small buffer (~0.1s)` so the breath doesn't fight the entry tail. `sin(0) = 0` at this moment, so the offset is exactly the entry's resting state — no jump
|
||||
- **IDLE_DUR** — breath tween length.
|
||||
- Constraints: must equal `TOTAL_DURATION − IDLE_START_TIME` to fill the composition with motion
|
||||
- **CYCLES** — number of full breath cycles across `IDLE_DUR`.
|
||||
- Range: `IDLE_DUR / 3s ≤ CYCLES ≤ IDLE_DUR / 1.5s` (cycle period 1.5-3s reads as natural breathing)
|
||||
- **SCALE_AMP** — sine amplitude on scale (hero).
|
||||
- **Default: 0.008-0.015** (barely-perceptible breath — the right answer for most scenes)
|
||||
- Push to 0.02-0.04 only when the element is **alone on canvas**, the scene is **short (< 6s)**, or the brief explicitly calls for **kinetic / playful** register
|
||||
- See Key Principles for the long-idle / concurrent-element scaling rules
|
||||
- **Y_AMP_PX** — sine amplitude on y translation (hero).
|
||||
- **Default: 2-3 px** (barely-perceptible — the right answer for most scenes)
|
||||
- Push to 4-6 px only when isolated / short / kinetic — same gating as `SCALE_AMP`
|
||||
- **DOT_SCALE_AMP** — sine amplitude on dot scale (offset by π/2 for out-of-phase motion).
|
||||
- Range: 0.04-0.12 — larger than hero amplitude is fine because the dot is a small accent
|
||||
- **PERIOD** (conditional-activation variation) — seconds per cycle when using the `(time - IDLE_START_TIME) / PERIOD` form.
|
||||
- Range: 1.5-3s
|
||||
- **GATE_THRESHOLD** (conditional-activation variation) — entryProgress required to start idle.
|
||||
- Range: 0.85-1.0; lower gates start idle slightly before entry completes for an overlap
|
||||
|
||||
### Multi-octave variation
|
||||
|
||||
- **SCALE_AMP_PRIMARY / SCALE_AMP_SECONDARY** — amplitudes of the two stacked sines.
|
||||
- Constraints: `SCALE_AMP_PRIMARY > SCALE_AMP_SECONDARY` (secondary is a higher-frequency overlay, not a peer); combined max amplitude should stay within the SCALE_AMP range above
|
||||
- **OCTAVE_RATIO** — frequency multiplier of secondary relative to primary.
|
||||
- Range: 2.0-4.0 (whole-number-ish ratios feel musical/coherent; non-integer ratios feel organic/unpredictable)
|
||||
|
||||
### Color tokens
|
||||
|
||||
- **{bgGradient}** — typically a dark radial gradient so the lit hero pops
|
||||
- **{textColor}** — high-contrast against `{bgGradient}`
|
||||
- **{accentColor}** — single accent reserved for the dot; the glow color in `{accentGlow}` is the same hue
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Prefer reveal, then jitter, then breath** — circular breathing as "aliveness" is cheap and reads weak; "no motion over bad motion." First fill the back of a shot with **sequential reveal timed to the VO**; if it's genuinely settled and still feels dead, use this rule at the **LOW end of its amplitude range as subtle jitter**; a full breathing loop is the rare last resort on a single held hero, never stamped on every element.
|
||||
- **`sin(0) = 0`** — at the moment idle begins, the offset must be zero so there's no visible jump from the entry's settled state to idle. Start the phase tween at `phase = 0`.
|
||||
- **Amplitude subtlety — default to the LOW end of the range.** Scale `0.008-0.015` (push to 0.02-0.04 only when isolated / short scene / kinetic brief), rotation `±0.3-0.8°` (rarely needed at all), translation `±2-3px` (push to 4-6px only when isolated). Bigger and idle reads as "still animating" instead of "alive but resting" — and a viewer watching 5+ consecutive scenes at the upper end will read the whole film as "shimmering."
|
||||
- **Cycle duration: 2.5-4s per breath when idle is long, 1.5-3s otherwise** — 2.5-3s is a comfortable breathing cadence; under 1.5s feels frantic in a long-idle window; over 4s feels lifeless in a short one.
|
||||
- **Long idle window (`IDLE_DUR > 6s` OR idle proportion > 30% of composition):** halve `SCALE_AMP` and `Y_AMP_PX`, slow `CYCLES` so each breath is 3-4s. Consider gating amplitude to fade to zero over the last ~20% of idle so the scene actually **settles before the transition**, instead of handing off mid-drift. This is the single biggest fix when finalize snapshots show "everything's still moving at the end."
|
||||
- **Concurrent idle on N elements** (triptych columns, card grid, multi-stat row, side-by-side panels): per-element amplitude ≤ default `/ √N`. Three columns each at `±6px` visually adds to `±18px+` of competing motion; three at `±2-3px` reads as one collective breath. Stagger the **period** between elements (2.1s / 1.9s / 2.4s) for organic feel — but the **amplitude** must also be smaller, not just the period.
|
||||
- **Different elements at different phases** — offset secondary elements by `Math.PI / 2` (90° offset) so they're not all moving in sync. Synced motion looks mechanical; out-of-phase looks alive.
|
||||
- **Compose, don't replace** — idle motion ADDS to the element's resting transform, not replace it. If the entry settled at `translateY(0)`, idle should produce `translateY(0 + sin*4)`. Don't overwrite the entry's final translation.
|
||||
- **❗ Don't use CSS `@keyframes` for the idle loop** — CSS animation runs on the browser's render clock, which is independent of the HF seek clock. HF seeks frame-by-frame and a CSS-driven idle will flicker/desync. Drive idle inside the GSAP timeline.
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Timeline must be paused**: `gsap.timeline({ paused: true })`
|
||||
- **Registry key = `data-composition-id`**
|
||||
- **No CSS `animation`** for idle — must be timeline-driven
|
||||
- **`will-change: transform`** if the idle compounds with other tweens on the same element
|
||||
- **Phase tween `ease: 'none'`** — sine itself provides the easing; tweening the phase non-linearly produces non-sinusoidal motion
|
||||
- **Don't restart the idle tween** — it's a single long tween from start to end of composition idle window
|
||||
|
||||
## Combinations
|
||||
|
||||
- After [press-release-spring.md](press-release-spring.md) — button idle-breathes after release settles
|
||||
- After [counting-dynamic-scale.md](counting-dynamic-scale.md) — final number breathes
|
||||
- After [card-morph-anchor.md](card-morph-anchor.md) — settled card idle-bobs
|
||||
- After [orbit-3d-entry.md](orbit-3d-entry.md) — center label idle-breathes while items orbit
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — `onUpdate` writing transform
|
||||
- `/hyperframes-core` — composition wiring
|
||||
- `/hyperframes-cli` — `hyperframes lint`
|
||||
@@ -0,0 +1,277 @@
|
||||
---
|
||||
name: split-tilt-cards
|
||||
description: Two cards side-by-side with opposing Y-rotation creating a symmetric 3D split-screen layout for comparisons or feature pairs.
|
||||
metadata:
|
||||
tags: 3d, cards, split, tilt, comparison, symmetric, layout
|
||||
---
|
||||
|
||||
# Split Tilt Cards
|
||||
|
||||
Two cards positioned side-by-side, each rotated in opposite Y directions. Creates a symmetric "book-open" 3D effect — natural fit for comparisons, before/after, or feature pairs.
|
||||
|
||||
## How It Works
|
||||
|
||||
- Left card rotates `+Y` (faces toward the right viewer angle)
|
||||
- Right card rotates `-Y` (faces toward the left viewer angle)
|
||||
- Both share the same `perspective` parent → opposing rotations balance visually
|
||||
- Each card enters from outside (left card slides in from the left, right card from the right) to reinforce its identity
|
||||
- Idle phase: gentle counter-phase float (`Math.PI` offset on sine) — cards bob in opposition
|
||||
|
||||
## HTML
|
||||
|
||||
```html
|
||||
<div
|
||||
class="scene"
|
||||
id="split-scene"
|
||||
data-composition-id="split-scene"
|
||||
data-start="0"
|
||||
data-duration="4"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div class="split-stage">
|
||||
<div class="card card-left">
|
||||
<div class="card-eyebrow">{leftEyebrow}</div>
|
||||
<div class="card-headline">{leftHeadline}</div>
|
||||
<div class="card-body">{leftBody}</div>
|
||||
</div>
|
||||
<div class="card card-right">
|
||||
<div class="card-eyebrow">{rightEyebrow}</div>
|
||||
<div class="card-headline">{rightHeadline}</div>
|
||||
<div class="card-body">{rightBody}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS
|
||||
|
||||
```css
|
||||
.scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: {bgGradient};
|
||||
perspective: SCENE_PERSPECTIVE; /* REQUIRED — without perspective rotateY flattens */
|
||||
}
|
||||
.split-stage {
|
||||
display: flex;
|
||||
gap: STAGE_GAP;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
.card {
|
||||
width: CARD_WIDTH;
|
||||
min-height: CARD_MIN_HEIGHT;
|
||||
padding: CARD_PADDING;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: CARD_INNER_GAP;
|
||||
border-radius: CARD_RADIUS;
|
||||
background: {cardSurface};
|
||||
border: 1px solid {cardBorder};
|
||||
color: {textColor};
|
||||
font-family: {font};
|
||||
transform-style: preserve-3d;
|
||||
will-change: transform;
|
||||
}
|
||||
.card-left {
|
||||
/* Faces right → shadow falls right */
|
||||
box-shadow:
|
||||
-CARD_SHADOW_OFFSET CARD_SHADOW_DROP CARD_SHADOW_BLUR {shadowColor},
|
||||
0 0 CARD_GLOW_BLUR {accentGlowColor};
|
||||
}
|
||||
.card-right {
|
||||
/* Faces left → shadow falls left */
|
||||
box-shadow:
|
||||
CARD_SHADOW_OFFSET CARD_SHADOW_DROP CARD_SHADOW_BLUR {shadowColor},
|
||||
0 0 CARD_GLOW_BLUR {accentGlowColor};
|
||||
}
|
||||
.card-eyebrow {
|
||||
font-size: EYEBROW_FONT_SIZE;
|
||||
font-weight: 800;
|
||||
letter-spacing: EYEBROW_LETTER_SPACING;
|
||||
text-transform: uppercase;
|
||||
color: {accentColor};
|
||||
}
|
||||
.card-headline {
|
||||
font-size: HEADLINE_FONT_SIZE;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
letter-spacing: HEADLINE_LETTER_SPACING;
|
||||
}
|
||||
.card-body {
|
||||
font-size: BODY_FONT_SIZE;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
color: {bodyColor};
|
||||
opacity: BODY_OPACITY;
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// Phase 1 — entry from outside
|
||||
tl.fromTo(
|
||||
".card-left",
|
||||
{ x: -ENTRY_SLIDE_DIST, rotateY: TILT + TILT_OVERSHOOT, opacity: 0 },
|
||||
{ x: 0, rotateY: TILT, opacity: 1, duration: ENTRY_DUR, ease: "power3.out" },
|
||||
LEFT_AT,
|
||||
);
|
||||
tl.fromTo(
|
||||
".card-right",
|
||||
{ x: ENTRY_SLIDE_DIST, rotateY: -TILT - TILT_OVERSHOOT, opacity: 0 },
|
||||
{ x: 0, rotateY: -TILT, opacity: 1, duration: ENTRY_DUR, ease: "power3.out" },
|
||||
RIGHT_AT,
|
||||
);
|
||||
|
||||
// Phase 2 — counter-phase idle bob (cards move in opposition for dynamism)
|
||||
tl.to(
|
||||
".card-left",
|
||||
{ y: -FLOAT_AMP, duration: FLOAT_DURATION / 2, ease: "sine.inOut", yoyo: true, repeat: 1 },
|
||||
IDLE_START,
|
||||
);
|
||||
tl.to(
|
||||
".card-right",
|
||||
{ y: FLOAT_AMP, duration: FLOAT_DURATION / 2, ease: "sine.inOut", yoyo: true, repeat: 1 },
|
||||
IDLE_START,
|
||||
);
|
||||
|
||||
// Phase 3 — gentle copy reveal (body slides up + fades after cards arrive)
|
||||
tl.from(
|
||||
".card-eyebrow, .card-headline, .card-body",
|
||||
{ opacity: 0, y: COPY_RISE, stagger: COPY_STAGGER, duration: COPY_DUR, ease: "power2.out" },
|
||||
COPY_REVEAL_AT,
|
||||
);
|
||||
|
||||
window.__timelines["split-scene"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
### Layout / typography
|
||||
|
||||
- **SCENE_PERSPECTIVE** — perspective on the scene root.
|
||||
- Range: 1000-2400 px
|
||||
- Effects: lower exaggerates the tilt (more cone-like); higher reads as a near-isometric, flatter rotation
|
||||
- **STAGE_GAP** — horizontal gap between the two cards.
|
||||
- Range: 40-120 px (≈0.06-0.15× `CARD_WIDTH`)
|
||||
- Effects: small gap reads "fused pair"; large gap reads "compared but separate"
|
||||
- **CARD_WIDTH** — width of each card.
|
||||
- Range: 480-820 px at 1920×1080
|
||||
- Constraints: `2 * CARD_WIDTH + STAGE_GAP ≤ 0.95 * stageWidth` so both cards stay on-screen at full tilt
|
||||
- **CARD_MIN_HEIGHT** — minimum card height.
|
||||
- Range: ≈0.75-1.05× `CARD_WIDTH` (square-ish reads as balanced; very tall reads as poster)
|
||||
- **CARD_PADDING / CARD_INNER_GAP / CARD_RADIUS** — interior chrome.
|
||||
- Range: padding 40-72 px; inner gap 24-48 px; radius 24-40 px
|
||||
- **CARD_SHADOW_OFFSET / CARD_SHADOW_DROP / CARD_SHADOW_BLUR** — drop-shadow geometry.
|
||||
- Constraints: offset's sign matches tilt direction (see Key Principles); drop > 0 grounds the card on the scene
|
||||
- Range: offset 16-28 px; drop 20-32 px; blur 40-80 px
|
||||
- **CARD_GLOW_BLUR** — secondary inner glow blur radius (`box-shadow` 0 0 blur).
|
||||
- Range: 16-32 px (subtle accent rim; larger competes with the card content)
|
||||
- **EYEBROW_FONT_SIZE / EYEBROW_LETTER_SPACING** — small uppercase label.
|
||||
- Range: 22-32 px; spacing 6-12 px (uppercase reads cleaner with positive letter-spacing)
|
||||
- **HEADLINE_FONT_SIZE / HEADLINE_LETTER_SPACING** — the main one-line punch.
|
||||
- Range: 72-104 px at 1920×1080; spacing -1 to -3 px (tight tracking for display weight)
|
||||
- **BODY_FONT_SIZE / BODY_OPACITY** — supporting copy.
|
||||
- Range: 28-40 px; opacity 0.8-0.92
|
||||
- Constraints: body limited to ≤2 lines (see Critical Constraints — tilted long paragraphs blur)
|
||||
|
||||
### Entry / tilt
|
||||
|
||||
- **TILT** — static `rotateY` magnitude in degrees (left `+`, right `−`).
|
||||
- Range: 10-18° (under 10 reads almost flat; over 18 the cards fold shut and body copy becomes hard to read)
|
||||
- **TILT_OVERSHOOT** — extra degrees added to the starting `rotateY` before settling to `TILT`.
|
||||
- Range: 4-12°
|
||||
- Effects: gives the entry a slight pivot-into-place feel
|
||||
- **ENTRY_SLIDE_DIST** — pixels each card slides from off-axis.
|
||||
- Range: 200-500 px (≈0.3-0.6× `CARD_WIDTH`)
|
||||
- **ENTRY_DUR** — per-card slide-in duration.
|
||||
- Range: 0.6-1.2 s
|
||||
- **LEFT_AT** — left card entry start.
|
||||
- Range: 0.0-0.4 s
|
||||
- **RIGHT_AT** — right card entry start.
|
||||
- Range: `LEFT_AT + 0.0` to `LEFT_AT + 0.3` s (zero stagger feels mechanical; large stagger fragments the pair)
|
||||
|
||||
### Idle bob
|
||||
|
||||
- **FLOAT_AMP** — sine amplitude on idle y bob (px).
|
||||
- Range: 3-8 px (see sine-wave-loop rule — subtle is the point)
|
||||
- **FLOAT_DURATION** — full yoyo round-trip duration (one breath).
|
||||
- Range: 1.6-3.2 s (≈breathing cadence)
|
||||
- **IDLE_START** — when idle bob begins.
|
||||
- Constraints: `≥ max(LEFT_AT, RIGHT_AT) + ENTRY_DUR` so idle doesn't fight the entry tail
|
||||
|
||||
### Copy reveal
|
||||
|
||||
- **COPY_REVEAL_AT** — when eyebrow/headline/body fade in.
|
||||
- Constraints: usually starts during the cards' settle (overlaps the entry tail) — content shouldn't pop in after the cards are already idle
|
||||
- **COPY_DUR / COPY_STAGGER / COPY_RISE** — fade-up shape.
|
||||
- Range: duration 0.4-0.7 s; stagger 0.04-0.10 s; rise 12-24 px
|
||||
|
||||
### Color / typography tokens
|
||||
|
||||
- **{bgGradient}** — radial or linear gradient behind the cards; darker than `{cardSurface}` so cards lift
|
||||
- **{cardSurface}** — card background (typically a low-saturation gradient layered over the scene)
|
||||
- **{cardBorder}** — 1 px border color, usually `{accentColor}` at low alpha
|
||||
- **{shadowColor}** — drop-shadow color, typically near-black at 0.5-0.7 alpha
|
||||
- **{accentGlowColor}** — inner glow color, typically `{accentColor}` at low alpha
|
||||
- **{accentColor}** — eyebrow + accent rim color (single hue per scene)
|
||||
- **{textColor}** — primary headline color, high contrast against `{cardSurface}`
|
||||
- **{bodyColor}** — body copy color, slightly desaturated vs `{textColor}`
|
||||
- **{font}** — display font stack for all card copy
|
||||
|
||||
## Variations
|
||||
|
||||
### Mid-tilt zoom-through (combined with camera move)
|
||||
|
||||
If a separate camera tween scales `.split-stage`, the cards' tilt reads as the viewer crossing through the gap between them.
|
||||
|
||||
### Asymmetric content density (badge / label / icon)
|
||||
|
||||
Add a floating badge near each card for additional context. Position absolutely on the parent — not inside the card, so the badge doesn't inherit the 3D rotation:
|
||||
|
||||
```html
|
||||
<div class="badge badge-left">{leftBadge}</div>
|
||||
<div class="badge badge-right">{rightBadge}</div>
|
||||
```
|
||||
|
||||
### Stacked variants (3+ cards)
|
||||
|
||||
For 3 cards, the center card stays flat (`rotateY 0`) and the outer two tilt inward — useful for "your old way / nothing in between / our way" comparisons.
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **`perspective` on scene root REQUIRED** — without it rotateY flattens and the split-tilt collapses to a flat side-by-side layout
|
||||
- **`transform-style: preserve-3d`** on both the stage and each card — preserves the 3D plane as cards have their own transforms
|
||||
- **Shadow direction must match tilt** — left card faces right, shadow falls right (positive X), and vice versa. Wrong shadow direction reads as "broken 3D"
|
||||
- **Symmetric content weight** — both cards same width, same vertical center, similar line counts. Asymmetric content breaks the comparison metaphor
|
||||
- **Counter-phase float (`Math.PI` offset)** — left bobs up while right bobs down. Synchronized bob looks like both cards are on the same conveyor belt; counter-phase looks alive
|
||||
- **Slide-in from the outside** — left card from left, right card from right — reinforces "they came from their own worlds and met here"
|
||||
- **❗ Tilt magnitude 10-15°** — under 10° looks like a slight perspective offset (almost flat), over 18° looks like the cards are folding shut and copy becomes hard to read
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Timeline must be paused**: `gsap.timeline({ paused: true })`
|
||||
- **Registry key = `data-composition-id`**
|
||||
- **No `requestAnimationFrame`** for the idle float — drive it inside the timeline so seek is deterministic
|
||||
- **Don't put badges inside the card divs** — they'd inherit the rotateY and tilt off-axis with the card. Float them on the parent
|
||||
- **Body copy ≤ 2 lines per card** — tilted text becomes hard to read; long paragraphs collapse into a perspective blur
|
||||
|
||||
## Combinations
|
||||
|
||||
- [card-morph-anchor.md](card-morph-anchor.md) — both cards could morph into a single unified shape afterward
|
||||
- [counting-dynamic-scale.md](counting-dynamic-scale.md) — numbers as the headline content for each side
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — timeline + `yoyo` for the idle bob
|
||||
- `/hyperframes-core` — composition wiring
|
||||
- `/hyperframes-cli` — `hyperframes lint`
|
||||
@@ -0,0 +1,274 @@
|
||||
---
|
||||
name: spring-pop-entrance
|
||||
description: The canonical entrance pop — an element (or staggered group) arrives by scaling 0 → 1 on a smooth long-tail settle (power3 default); bouncy overshoot is a rare, explicitly-playful exception. fromTo so it's correct at t=0 under seek.
|
||||
metadata:
|
||||
tags: spring, entrance, pop, scale, power3, settle, stagger, reveal, arrival
|
||||
---
|
||||
|
||||
# Spring-Pop Entrance
|
||||
|
||||
> **Smooth beats bouncy.** Per the motion doctrine (`references/motion-language.md`), this entrance **defaults to a smooth long-tail settle — `power3.out` (or `expo.out` for a faster arrival)** that decelerates cleanly into the resting size with **no overshoot**. Bouncy `back.out` overshoot is the **#1 instant turn-off** in agent-made videos and is almost never executed well; it is demoted here to a **rare, explicitly-playful exception** (a consumer / fun brand), never the default. When unsure, settle smoothly.
|
||||
|
||||
THE entrance primitive: an element (or a staggered group of them) arrives on screen by springing from nothing — `scale: 0 → 1`, optionally with a small `y` rise — riding a **smooth long-tail ease (`power3.out` default)** so it grows confidently into its resting size and settles without bouncing. This is **arrival**, not reaction.
|
||||
|
||||
Explicitly distinct from [press-release-spring.md](press-release-spring.md): that rule is a click/press → release feedback chain (a press phase, then a spring recovery to `1.0`). This one has **no press phase** — there is no prior resting state, the element did not exist on screen, it springs into being. Many blueprints used to borrow `press-release-spring` to fake an entrance; reach for this instead.
|
||||
|
||||
## How It Works
|
||||
|
||||
A single `fromTo` carries the whole arrival:
|
||||
|
||||
1. **From-state**: `{ scale: 0, opacity: 0 }` — the element is collapsed to a point and invisible. Stated explicitly in the `from` object so a seek to `t=0` lands the element in this exact state (never rely on a CSS-hidden start — see Critical Constraints).
|
||||
2. **To-state (default)**: `{ scale: 1, opacity: 1, ease: "power3.out" }` — a long-tail decel that grows the element into its resting size and **settles smoothly, no overshoot**. Use `expo.out` instead for a punchier, faster-front arrival (still no bounce). This smooth settle is the house style; the bouncy `back.out` variant is the rare playful exception (see Variations).
|
||||
|
||||
For a **group**, the same `fromTo` runs per element with a **deterministic, index-derived stagger** (`i * STAGGER`), and the total entry window is **capped** (`ITEM_COUNT × STAGGER ≤ ~0.5s`) so the group reads as one arriving beat, not a slow arpeggio.
|
||||
|
||||
A small `y` rise (`y: 24 → 0`) layers a subtle "lifts into place" on top of the pop — optional garnish; the `scale` grow on a smooth ease is the load-bearing motion. (A `rotation` settle belongs only to the playful overshoot variant below.)
|
||||
|
||||
## HTML
|
||||
|
||||
```html
|
||||
<!-- Single hero pop -->
|
||||
<div
|
||||
class="scene"
|
||||
data-composition-id="pop-scene"
|
||||
data-start="0"
|
||||
data-duration="3"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div class="pop-hero" id="hero">{heroLabel}</div>
|
||||
</div>
|
||||
|
||||
<!-- Staggered group: nodes / cards / icons / pills / callouts -->
|
||||
<div
|
||||
class="scene"
|
||||
data-composition-id="pop-group-scene"
|
||||
data-start="0"
|
||||
data-duration="3"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div class="pop-grid">
|
||||
<div class="pop-item">{itemA}</div>
|
||||
<div class="pop-item">{itemB}</div>
|
||||
<div class="pop-item">{itemC}</div>
|
||||
<div class="pop-item">{itemD}</div>
|
||||
<div class="pop-item">{itemE}</div>
|
||||
<div class="pop-item">{itemF}</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS
|
||||
|
||||
```css
|
||||
.scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: {bgColor};
|
||||
}
|
||||
.pop-hero {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: {heroSize};
|
||||
height: {heroSize};
|
||||
background: {heroBg};
|
||||
border-radius: HERO_RADIUS;
|
||||
font-family: {font};
|
||||
font-weight: 900;
|
||||
font-size: HERO_FONT_SIZE;
|
||||
color: {heroTextColor};
|
||||
/* Pop scales around the center — see Critical Constraints */
|
||||
transform-origin: 50% 50%;
|
||||
will-change: transform;
|
||||
}
|
||||
.pop-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: GRID_GAP;
|
||||
place-items: center;
|
||||
}
|
||||
.pop-item {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: {itemSize};
|
||||
height: {itemSize};
|
||||
background: {itemBg};
|
||||
border-radius: ITEM_RADIUS;
|
||||
font-family: {font};
|
||||
font-weight: 800;
|
||||
font-size: ITEM_FONT_SIZE;
|
||||
color: {itemTextColor};
|
||||
transform-origin: 50% 50%;
|
||||
will-change: transform;
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// --- Single hero pop (default: smooth long-tail settle, no overshoot) ---
|
||||
// fromTo states the collapsed start explicitly so the hero is correct at t=0
|
||||
// under seek. power3.out grows scale into 1.0 and decelerates smoothly.
|
||||
tl.fromTo(
|
||||
"#hero",
|
||||
{ scale: 0, opacity: 0 },
|
||||
{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
duration: POP_DUR,
|
||||
ease: "power3.out", // smooth beats bouncy; expo.out for a punchier front
|
||||
},
|
||||
ENTRY_AT,
|
||||
);
|
||||
|
||||
// --- Staggered group pop ---
|
||||
// Deterministic, index-derived stagger (no Math.random). The cap keeps the
|
||||
// whole group inside one arriving beat: ITEM_COUNT * STAGGER <= ~0.5s.
|
||||
const items = gsap.utils.toArray(".pop-item");
|
||||
items.forEach((el, i) => {
|
||||
tl.fromTo(
|
||||
el,
|
||||
{ scale: 0, opacity: 0, y: Y_RISE },
|
||||
{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
duration: POP_DUR,
|
||||
ease: "power3.out",
|
||||
},
|
||||
GROUP_ENTRY_AT + i * STAGGER,
|
||||
);
|
||||
});
|
||||
|
||||
window.__timelines["pop-scene"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
### Calm settle (refined / enterprise / "premium calm") — default
|
||||
|
||||
`power3.out`, no rotation, drop the `y` rise or keep it tiny (~12px). Reads as a confident, weighted settle — right for a hero wordmark or a single product shot landing. The safe default for premium / enterprise brands.
|
||||
|
||||
### Firm settle (default product reveal) — default
|
||||
|
||||
The everyday entrance. `power3.out` (or `expo.out` for a punchier front), optional `Y_RISE` ~24px. Clear, deliberate arrival that decelerates clean — the safe default for cards, icons, and callouts. **No overshoot.**
|
||||
|
||||
### Bouncy pop (RARE — explicitly-playful only)
|
||||
|
||||
The exception, not the default. **Only** for a deliberately playful register (a consumer / fun brand, a toy-like icon set) where a bounce is clearly the intent — never for product / enterprise / serious launch tone. Bouncy is the #1 turn-off and the agent rarely lands it, so reach for this knowingly and sparingly. Swap `power3.out` for `back.out(OVERSHOOT)` and (optionally) add a `rotation` settle so each element looks hand-placed:
|
||||
|
||||
```js
|
||||
// Playful exception only — default to power3.out (see above).
|
||||
tl.fromTo(
|
||||
el,
|
||||
{ scale: 0, opacity: 0, rotation: ROT_FROM },
|
||||
{ scale: 1, opacity: 1, rotation: 0, duration: POP_DUR, ease: `back.out(${OVERSHOOT})` },
|
||||
GROUP_ENTRY_AT + i * STAGGER,
|
||||
);
|
||||
```
|
||||
|
||||
Keep `OVERSHOOT` modest even here (≤ ~2) — past that it reads as a cartoon wobble, not an arrival. Better still: the baked spring at `dampingFraction: 0.6–0.7` (`../adapters/gsap-easing-and-stagger.md` → Spring Eases) gives ~5–10% overshoot with a second-order settle that reads physical where `back.out` reads cartoon.
|
||||
|
||||
### Origin-anchored pop (callout springs from a pointer / source)
|
||||
|
||||
When a callout should appear to grow out of a specific point (e.g. a station marker or pointer tip), set `transform-origin` to that point instead of center, so the `scale: 0 → 1` reads as "emerging from the source" rather than "inflating in place."
|
||||
|
||||
```css
|
||||
.callout {
|
||||
transform-origin: 0% 100%; /* bottom-left = pointer tip; match to the anchor */
|
||||
}
|
||||
```
|
||||
|
||||
### Pop into a held slot — then hold (jitter at most)
|
||||
|
||||
When a popped element then **holds** an ongoing slot (a constellation node, a persistent badge), do **not** bake an idle loop into this entrance — it must stay finite. Land the pop and let it hold still; if the held frame genuinely needs life, hand off to [sine-wave-loop.md](sine-wave-loop.md) for **subtle jitter** (low amplitude) on a separate, later tween — not a breathing loop. Prefer revealing the next element on its VO cue over keeping this one animating.
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
- **EASE** — the settle curve (the load-bearing decision)
|
||||
- Default: **`power3.out`** — a smooth long-tail settle, no overshoot; the house style for product / enterprise / serious tone. Use `expo.out` for a punchier, faster-front arrival (still smooth).
|
||||
- Exact-physics option: `springEase({ response: 0.4 })` (critically damped, ζ=1) from `../adapters/gsap-easing-and-stagger.md` → Spring Eases — the curve `power3.out` approximates, with a harder front and a longer settle tail; take `duration` from the helper. Use when the settle IS the shot (a wordmark landing, a final lockup).
|
||||
- Playful exception only: `back.out(OVERSHOOT)` — see the Bouncy pop variation; reach for it only when a bounce is clearly the brand intent.
|
||||
|
||||
- **OVERSHOOT** — `back.out(OVERSHOOT)` overshoot strength — **only used in the rare bouncy variant**; the smooth default has no overshoot dial
|
||||
- Range (playful only): ~1.3 (barely) → ~2.0 (clearly bouncy)
|
||||
- Constraints: keep ≤ ~2 — past that the overshoot exceeds the element's bounds and reads as a cartoon wobble, not an arrival. If you're not in the explicitly-playful case, don't use this — use `power3.out`.
|
||||
|
||||
- **POP_DUR** — duration of each element's `scale: 0 → 1` tween
|
||||
- Range: 0.4 – 0.7 s
|
||||
- Effects: shorter = tight snap; longer = a looser, more floating pop
|
||||
- Constraints: the main subject must be visible by **`t ≤ 0.5s`** — keep `ENTRY_AT + POP_DUR`'s readable midpoint early; don't let the hero finish arriving after the half-second mark
|
||||
|
||||
- **STAGGER** — gap between successive items' start times (group only)
|
||||
- Range: 0.04 – 0.08 s
|
||||
- Effects: < 0.04 reads as a simultaneous chord; > 0.08 feels lazy / arpeggiated
|
||||
- Constraints: **`ITEM_COUNT × STAGGER ≤ ~0.5s`** (the cap) — beyond that the group stops reading as one beat. Cap the per-item stagger for large groups: `STAGGER = min(0.06, 0.5 / ITEM_COUNT)`
|
||||
|
||||
- **ITEM_COUNT** — number of elements in a group pop
|
||||
- Range: 3 – 9
|
||||
- Effects: 3 = sparse; 9 = full grid. More than ~9 forces `STAGGER` so small the stagger vanishes — switch to a wipe/sweep reveal instead
|
||||
|
||||
- **Y_RISE** — optional upward offset the element lifts from (`y: Y_RISE → 0`)
|
||||
- Range: 0 (pure pop) – 32 px
|
||||
- Effects: adds a subtle "lifts into place"; keep small so the `scale` pop stays dominant
|
||||
- Constraints: 0 for the calm-settle variant; never large enough to read as a slide-up (that's a different primitive)
|
||||
|
||||
- **ROT_FROM** — optional starting rotation, **playful (bouncy) variant only** (`rotation: ROT_FROM → 0`)
|
||||
- Range: −10° – +10°
|
||||
- Effects: a small tilt that resolves makes the element look hand-placed
|
||||
- Constraints: derive sign/size deterministically from index if you want alternating tilt (e.g. `i % 2 ? 6 : -6`) — never `Math.random`
|
||||
|
||||
- **ENTRY_AT / GROUP_ENTRY_AT** — timeline offset before the (group's) pop begins
|
||||
- Range: 0 – 0.4 s
|
||||
- Effects: > 0 gives a beat of quiet before the arrival; keep small so the subject still lands by `t ≤ 0.5s`
|
||||
|
||||
### Geometry & tokens
|
||||
|
||||
- **{heroSize} / {itemSize}** — footprints. A hero entrance should occupy a clearly readable share of the frame; group items size down so the grid fits with `GRID_GAP` breathing room.
|
||||
- **HERO_RADIUS / ITEM_RADIUS** — `height × 0.15` (sharp) → `height / 2` (pill).
|
||||
- **{heroBg} / {itemBg} / {\*TextColor}** — surface + label tokens; inherit from the composition palette.
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Smooth beats bouncy** — default to `power3.out` (or `expo.out`): a long-tail settle into `scale: 1`, no overshoot. Bouncy `back.out` is the rare, explicitly-playful exception (the #1 turn-off, and the agent rarely lands it). When unsure, settle smoothly.
|
||||
- **fromTo, always** — the collapsed `{ scale: 0, opacity: 0 }` start is stated in the `from` object so a seek to `t=0` lands it exactly there. An entrance built on a CSS-hidden start (e.g. `opacity:0` in CSS + a `.to()`) flickers under HF seek — the element renders visible before the tween claims it.
|
||||
- **Easing carries the motion, not keyframes** — let the ease produce the settle for free. Don't hand-key a `scale: 1.1` mid-state; that double-bounces and fights the curve. (And in the playful variant, the overshoot is a byproduct of `back.out`, not a hand-keyed bounce.)
|
||||
- **The grow is the motion** — `scale` is load-bearing; the `y` rise (and, in the playful variant, the `rotation` settle) is garnish layered on top. If you drop everything but the `scale` grow, it should still read as a clean entrance.
|
||||
- **Cap the stagger window** — a group must arrive inside ~0.5s total or it stops reading as one beat and starts reading as a slow list reveal. Derive the stagger from `ITEM_COUNT` so it self-caps.
|
||||
- **Deterministic per index** — all stagger and any rotation/tilt variation comes from the loop index, never `Math.random` — the renderer must produce the identical frame on every seek.
|
||||
- **Visible early** — the main subject must be on screen by `t ≤ 0.5s`. A hero that finishes arriving at `t=1s` wastes the opening beat.
|
||||
- **Don't bake an idle loop here** — this entrance is finite. If the element then holds a slot, hand off to `sine-wave-loop` on a later tween; an infinite `repeat`/`yoyo` here breaks seek.
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Timeline must be paused**: `gsap.timeline({ paused: true })`
|
||||
- **Registry key = `data-composition-id`**
|
||||
- **Entrances use `fromTo`** — explicit `{ scale: 0, opacity: 0 }` from-state; never rely on a CSS-hidden starting state
|
||||
- **No CSS `transition`** on popped elements — those interpolate independently of HF seek and cause flicker
|
||||
- **No `repeat` / `yoyo` / infinite tweens** — this is a finite arrival; idle motion is a separate `sine-wave-loop` tween
|
||||
- **No `Math.random` / `Date.now`** — stagger and tilt are index-derived and deterministic
|
||||
- **GSAP transform aliases only**: `x`, `y`, `scale`, `rotation`. Never tween `width` / `height` / `left` / `top`
|
||||
- **`transform-origin: 50% 50%`** for an in-place pop (default); set it to the source point only for the origin-anchored variation
|
||||
- **Default ease `power3.out`** (smooth, no overshoot); `back.out(OVERSHOOT)` only in the explicitly-playful variant, and there keep **`OVERSHOOT ≤ ~2`** — beyond that it reads as a cartoon wobble, not an arrival
|
||||
- **`ITEM_COUNT × STAGGER ≤ ~0.5s`** — the group must land inside one beat
|
||||
- **`will-change: transform`** on popped elements, especially groups — many simultaneous spring tweens benefit from compositor hints
|
||||
|
||||
## Combinations
|
||||
|
||||
- [sine-wave-loop.md](sine-wave-loop.md) — at most **subtle jitter** on a held node/badge AFTER its pop lands (don't bake any loop into the entrance; and prefer a VO-timed reveal over ambient motion — see that rule's caution)
|
||||
- [center-outward-expansion.md](center-outward-expansion.md) — elements pop in as they radiate from center to their slots
|
||||
- [press-release-spring.md](press-release-spring.md) — the reaction counterpart: once popped in, a button can take a press→release; this rule supplies the arrival, that one the click feedback
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — `power3.out` settle (smooth default), `fromTo` entrances, deterministic stagger
|
||||
- `/hyperframes-core` — composition wiring
|
||||
- `/hyperframes-cli` — `hyperframes lint`
|
||||
@@ -0,0 +1,156 @@
|
||||
---
|
||||
name: stat-bars-and-fills
|
||||
description: Data-viz primitives that pair a number with a graphic — growth bars (CSS scaleY stagger), a progress fill (bar or ring), and a partial star-rating wipe. Seek-safe, deterministic.
|
||||
metadata:
|
||||
tags: data, stats, chart, bars, progress, ring, stars, rating, infographic, number
|
||||
---
|
||||
|
||||
# Stat Bars & Fills
|
||||
|
||||
The graphics that give a stat **visual weight** beside its number: a small bar chart, a progress bar/ring filling to a percentage, or a star row filling to a fractional rating. Pair these with [counting-dynamic-scale.md](counting-dynamic-scale.md) (the number) for a complete stat scene.
|
||||
|
||||
**Layout blueprint — pick ONE and hold it across all stats:**
|
||||
|
||||
- **Single-focus** — one centered frame, the number is the hero, a ring or bar sits under/around it. Cleanest for a sequential reveal (stat 1 → stat 2 → stat 3 in the same frame).
|
||||
- **Split-frame** — big number on the left, paired graphic on the right. Better when stats are shown together or each needs a distinct visual.
|
||||
|
||||
Don't mix blueprints between stats in one piece — that reads as inconsistent.
|
||||
|
||||
## 1 — Growth Bars (CSS `scaleY` stagger)
|
||||
|
||||
Bars grow from the baseline with a stagger; the last bar is the accent.
|
||||
|
||||
```css
|
||||
.bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 14px;
|
||||
height: 280px;
|
||||
}
|
||||
.bar {
|
||||
width: 48px;
|
||||
background: #3a4a64;
|
||||
transform: scaleY(0);
|
||||
transform-origin: bottom center; /* grow UP from the baseline, not from center */
|
||||
}
|
||||
.bar:last-child {
|
||||
background: #ffc300;
|
||||
} /* accent the final/current bar */
|
||||
```
|
||||
|
||||
```js
|
||||
// Heights are authored in CSS (e.g. inline height per bar); GSAP only reveals scaleY 0→1.
|
||||
tl.to(".bar", { scaleY: 1, duration: 0.7, ease: "power3.out", stagger: 0.08 }, 0.3);
|
||||
```
|
||||
|
||||
> Use `scaleY` (a transform), never animate `height` — height tweens are forbidden by the runtime. Set each bar's final height in CSS, scale from 0.
|
||||
|
||||
## 2 — Progress Fill
|
||||
|
||||
**Bar form** — `scaleX` from a left origin:
|
||||
|
||||
```css
|
||||
.track {
|
||||
width: 520px;
|
||||
height: 16px;
|
||||
background: #1b263b;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* width:100% is REQUIRED — an absolutely-positioned fill with no width is 0px, and scaleX of 0 is
|
||||
still 0 → the bar renders invisible (and no lint/inspect check catches a zero-width scaled element). */
|
||||
.fill {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #ffc300;
|
||||
transform: scaleX(0);
|
||||
transform-origin: left center;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
const PCT = 0.92; // 92%
|
||||
tl.to(".fill", { scaleX: PCT, duration: 1.0, ease: "power2.out" }, 0.3);
|
||||
```
|
||||
|
||||
**Ring form** — measured stroke draw (delegates to [svg-path-draw.md](svg-path-draw.md)):
|
||||
|
||||
```js
|
||||
const ring = document.querySelector("#ring");
|
||||
const LEN = ring.getTotalLength(); // measure, don't hard-code the circumference
|
||||
ring.style.strokeDasharray = LEN;
|
||||
ring.style.strokeDashoffset = LEN; // empty
|
||||
// rotate the <circle> -90deg in CSS so the fill starts at 12 o'clock
|
||||
tl.to(ring, { strokeDashoffset: LEN * (1 - 0.92), duration: 1.1, ease: "power2.out" }, 0.3);
|
||||
```
|
||||
|
||||
## 3 — Star-Rating Fill (fractional)
|
||||
|
||||
A gold star row revealed left-to-right to a fractional value (e.g. 4.6 / 5) via a clip wipe over a gold layer sitting on a gray layer.
|
||||
|
||||
```html
|
||||
<div class="stars">
|
||||
<div class="stars-gray">★★★★★</div>
|
||||
<div class="stars-gold" id="goldStars">★★★★★</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
```css
|
||||
.stars {
|
||||
position: relative;
|
||||
font-size: 64px;
|
||||
letter-spacing: 8px;
|
||||
}
|
||||
.stars-gray {
|
||||
color: #2b3548;
|
||||
}
|
||||
.stars-gold {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
color: #ffc300;
|
||||
width: 100%;
|
||||
clip-path: inset(0 100% 0 0);
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
const RATING = 4.6,
|
||||
MAX = 5;
|
||||
tl.to(
|
||||
"#goldStars",
|
||||
{ clipPath: `inset(0 ${100 - (RATING / MAX) * 100}% 0 0)`, duration: 1.0, ease: "power2.out" },
|
||||
0.3,
|
||||
);
|
||||
```
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
- **Bar count** — 4–6 reads as "a trend" without clutter; the last bar is the current/accent value.
|
||||
- **Fill duration** — 0.8–1.2s, matched to the paired count-up so number and graphic land together (share the ease).
|
||||
- **Accent hue** — exactly one; bars/fill/stars all use the same accent, the rest is muted.
|
||||
- **Stagger** — 0.06–0.1s on bars; larger feels sluggish, 0 loses the build.
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Transforms only** — `scaleY` / `scaleX` / `clipPath`, never `width`/`height` tweens (runtime-forbidden).
|
||||
- **Match the number's timing** — the fill and the count-up should peak together (same start + ease), so the stat resolves as one beat, not two.
|
||||
- **Measure, don't hard-code** — ring length via `getTotalLength()`; a hard-coded circumference breaks if the radius changes.
|
||||
- **One accent hue, consistent blueprint** — see `hyperframes-creative/references/data-in-motion.md`.
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Timeline paused**; build synchronously; registry key = `data-composition-id`.
|
||||
- **`onUpdate` (if pairing a counter) must be O(1)** — the runtime seeks frame-by-frame (see [counting-dynamic-scale.md](counting-dynamic-scale.md)).
|
||||
- **No `height`/`width` tweens, no `repeat: -1`** — transforms + finite repeats only.
|
||||
- **`transform-origin`** must be `bottom` (bars grow up) / `left` (bars/fills grow right) — default center origin scales from the middle and looks wrong.
|
||||
|
||||
## Combinations
|
||||
|
||||
- [counting-dynamic-scale.md](counting-dynamic-scale.md) — the number beside the graphic (pair them; same ease/duration)
|
||||
- [svg-path-draw.md](svg-path-draw.md) — the progress-ring draw mechanics
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — timeline + transform tweens
|
||||
- `/hyperframes-creative` — `references/data-in-motion.md` (stat layout + visual weight)
|
||||
- `/hyperframes-core` — composition wiring; the no-`width`/`height`-tween rule
|
||||
@@ -0,0 +1,329 @@
|
||||
---
|
||||
name: svg-icon-enrichment
|
||||
description: Animate internal SVG elements (rotating hands, opening blades, pulsing dots, dash flows) to make icons feel alive without replacing them.
|
||||
metadata:
|
||||
tags: svg, icon, animation, internal, micro-animation, pulse, rotation
|
||||
---
|
||||
|
||||
# SVG Icon Enrichment
|
||||
|
||||
Treats an SVG icon as a composition of animated PARTS, not an opaque image. Each meaningful internal element (a clock hand, scissor blade, recording dot, data line) gets its own GSAP-driven micro-animation. Distinct from [svg-path-draw](svg-path-draw.md) (which animates the OUTLINE drawing) — enrichment animates INTERNAL PARTS, ideally after the outline has drawn.
|
||||
|
||||
## How It Works
|
||||
|
||||
The SVG is authored with named `<line>`, `<circle>`, `<path>`, or `<g>` children. The GSAP timeline targets these by selector and applies one of 4 signature motion patterns:
|
||||
|
||||
1. **Rotation** — clock hand, gear, loading spinner (`transform: rotate(deg)`)
|
||||
2. **Oscillation** — scissor blades, wing flap, toggle (`transform: rotate(±sin*amp)` on opposing groups)
|
||||
3. **Pulse** — recording dot, heart, notification (`scale + opacity` via sin)
|
||||
4. **Dash flow** — moving dashes along a stroke, like a data stream (`strokeDashoffset` linear)
|
||||
|
||||
All run inside the paused GSAP timeline so HF seeks deterministically.
|
||||
|
||||
## HTML
|
||||
|
||||
```html
|
||||
<div
|
||||
class="scene"
|
||||
data-composition-id="enrichment-scene"
|
||||
data-start="0"
|
||||
data-duration="4"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div class="stack">
|
||||
<div class="row">
|
||||
<!-- Clock icon — minute hand rotates -->
|
||||
<svg class="icon-svg" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="60" cy="60" r="50" fill="none" stroke="{accentColor}" stroke-width="6" />
|
||||
<line
|
||||
class="clock-hand"
|
||||
id="hand-min"
|
||||
x1="60"
|
||||
y1="60"
|
||||
x2="60"
|
||||
y2="22"
|
||||
stroke="{textColor}"
|
||||
stroke-width="6"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<line
|
||||
class="clock-hand"
|
||||
id="hand-sec"
|
||||
x1="60"
|
||||
y1="60"
|
||||
x2="60"
|
||||
y2="30"
|
||||
stroke="{recordColor}"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<circle cx="60" cy="60" r="6" fill="{textColor}" />
|
||||
</svg>
|
||||
|
||||
<!-- Recording dot — pulses -->
|
||||
<svg class="icon-svg" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle
|
||||
id="rec-ring"
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="50"
|
||||
fill="none"
|
||||
stroke="{recordColor}"
|
||||
stroke-width="4"
|
||||
/>
|
||||
<circle id="rec-dot" cx="60" cy="60" r="22" fill="{recordColor}" />
|
||||
</svg>
|
||||
|
||||
<!-- Data stream — dashes flow along the line -->
|
||||
<svg class="icon-svg" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect
|
||||
x="14"
|
||||
y="48"
|
||||
width="92"
|
||||
height="24"
|
||||
rx="12"
|
||||
fill="none"
|
||||
stroke="{accentColor}"
|
||||
stroke-width="4"
|
||||
/>
|
||||
<line
|
||||
id="data-flow"
|
||||
x1="14"
|
||||
y1="60"
|
||||
x2="106"
|
||||
y2="60"
|
||||
stroke="{accentColor}"
|
||||
stroke-width="6"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="14 12"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="brand">{brandPhrase}</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS
|
||||
|
||||
```css
|
||||
.scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: {bgColor};
|
||||
font-family: {font};
|
||||
}
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 80px;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 120px;
|
||||
}
|
||||
.icon-svg {
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
filter: drop-shadow(0 12px 32px {shadowColor});
|
||||
}
|
||||
.clock-hand {
|
||||
/* transform-origin in SVG must be in viewBox units, not pixels */
|
||||
transform-origin: 60px 60px;
|
||||
transform-box: fill-box;
|
||||
}
|
||||
.brand {
|
||||
font-size: 64px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 14px;
|
||||
text-transform: uppercase;
|
||||
color: {textColor};
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// Pattern 1 — Rotation (clock hands)
|
||||
// Minute hand: MIN_REVOLUTIONS full rotations over TOTAL_DURATION
|
||||
const minState = { deg: 0 };
|
||||
tl.to(
|
||||
minState,
|
||||
{
|
||||
deg: 360 * MIN_REVOLUTIONS,
|
||||
duration: TOTAL_DURATION,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
document.getElementById("hand-min").style.transform = `rotate(${minState.deg}deg)`;
|
||||
},
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
// Second hand: SEC_REVOLUTIONS full rotations over TOTAL_DURATION (faster)
|
||||
const secState = { deg: 0 };
|
||||
tl.to(
|
||||
secState,
|
||||
{
|
||||
deg: 360 * SEC_REVOLUTIONS,
|
||||
duration: TOTAL_DURATION,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
document.getElementById("hand-sec").style.transform = `rotate(${secState.deg}deg)`;
|
||||
},
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
// Pattern 2 — Pulse (recording dot, ring opacity inverse)
|
||||
const pulseState = { p: 0 };
|
||||
tl.to(
|
||||
pulseState,
|
||||
{
|
||||
p: Math.PI * 2 * PULSE_CYCLES,
|
||||
duration: TOTAL_DURATION,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
const dotScale = 1 + Math.sin(pulseState.p) * PULSE_DOT_AMP;
|
||||
const ringScale = 1 + Math.sin(pulseState.p + Math.PI / 2) * PULSE_RING_AMP;
|
||||
const ringOpacity =
|
||||
PULSE_RING_OPACITY_BASE + Math.sin(pulseState.p) * PULSE_RING_OPACITY_AMP;
|
||||
const dot = document.getElementById("rec-dot");
|
||||
const ring = document.getElementById("rec-ring");
|
||||
dot.style.transform = `scale(${dotScale})`;
|
||||
dot.style.transformOrigin = "60px 60px";
|
||||
ring.style.transform = `scale(${ringScale})`;
|
||||
ring.style.transformOrigin = "60px 60px";
|
||||
ring.style.opacity = String(ringOpacity);
|
||||
},
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
// Pattern 3 — Dash flow (data stream)
|
||||
const flowState = { offset: 0 };
|
||||
tl.to(
|
||||
flowState,
|
||||
{
|
||||
offset: DASH_FLOW_TOTAL_OFFSET, // negative for L→R flow, positive for R→L
|
||||
duration: TOTAL_DURATION,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
document.getElementById("data-flow").style.strokeDashoffset = String(flowState.offset);
|
||||
},
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
// Brand fades in early
|
||||
tl.from(".brand", { opacity: 0, y: 16, duration: 0.6, ease: "power3.out" }, BRAND_AT);
|
||||
|
||||
window.__timelines["enrichment-scene"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
- **MIN_REVOLUTIONS** — minute-hand revolutions across TOTAL_DURATION
|
||||
- Range: 0.5–2.0 (continuous; faster reads as time-lapse)
|
||||
- Constraints: avoid integer revolutions if visible end frame matters (lands back at start)
|
||||
|
||||
- **SEC_REVOLUTIONS** — second-hand revolutions across TOTAL_DURATION
|
||||
- Range: 4–10 (should be visibly faster than the minute hand)
|
||||
- Constraints: SEC_REVOLUTIONS > MIN_REVOLUTIONS × 3 for the speed difference to read
|
||||
|
||||
- **PULSE_CYCLES** — number of pulse cycles across TOTAL_DURATION
|
||||
- Range: 2–4 over a 3–5 s comp
|
||||
- Effects: ≥ 5 reads as anxious flicker; ≤ 1 reads as forgotten
|
||||
|
||||
- **PULSE_DOT_AMP** — dot scale amplitude
|
||||
- Range: 0.05–0.20
|
||||
- Effects: 0.05 = breathing; 0.20 = throbbing
|
||||
|
||||
- **PULSE_RING_AMP** — ring scale amplitude (typically lower than DOT_AMP)
|
||||
- Range: 0.04–0.12
|
||||
- Constraints: must be < PULSE_DOT_AMP or ring overshadows dot
|
||||
|
||||
- **PULSE_RING_OPACITY_BASE / PULSE_RING_OPACITY_AMP** — ring opacity baseline + sine amplitude
|
||||
- Range: BASE 0.4–0.6; AMP 0.3–0.5
|
||||
- Constraints: BASE − AMP ≥ 0 and BASE + AMP ≤ 1
|
||||
|
||||
- **DASH_FLOW_TOTAL_OFFSET** — total stroke-dashoffset change across TOTAL_DURATION
|
||||
- Range: −400 to −100 (negative for L→R) or +100 to +400 (R→L)
|
||||
- Effects: |large| = fast flow; |small| = slow drift
|
||||
- Constraints: must be an integer multiple of the dash period (dash + gap) or the loop end frame shows a phase jump
|
||||
|
||||
- **BRAND_AT** — when the brand phrase fades in
|
||||
- Range: 0.3–1.0 s
|
||||
- Effects: too early competes with icon entries; too late feels appended
|
||||
|
||||
- **Ease family choices**: rotation = `none` (linear motion is the point); pulse driver = `none` (sine handles the curve); reveal of brand = `power3.out`
|
||||
|
||||
## Signature Motion Patterns
|
||||
|
||||
| Pattern | Use For | Math | Tip |
|
||||
| ----------- | ---------------------------------- | ------------------------------------------------ | ----------------------------------- |
|
||||
| Rotation | Clock, gear, loader, dial | `transform: rotate(deg)`, linear via sec-counter | `transform-origin` in viewBox units |
|
||||
| Oscillation | Scissors, wings, toggle | `rotate(±sin*amp)` on opposing groups | Opposite signs on the two parts |
|
||||
| Pulse | Recording dot, heart, notification | `scale(1 + sin*amp)` + opacity | Ring lags dot by π/2 for ripple |
|
||||
| Dash flow | Cutting line, data stream | `strokeDashoffset` linear via time | Negative for L→R, positive for R→L |
|
||||
|
||||
## Variations
|
||||
|
||||
### Stroke draw → enrichment chain
|
||||
|
||||
Draw the icon outline first (via [svg-path-draw](svg-path-draw.md)), THEN activate enrichment. The internal animation feels like "the icon woke up" after assembly.
|
||||
|
||||
```js
|
||||
// Phase 1: outline draws (0 → OUTLINE_DUR)
|
||||
tl.fromTo(
|
||||
"#icon-outline",
|
||||
{ strokeDashoffset: 360 },
|
||||
{ strokeDashoffset: 0, duration: OUTLINE_DUR, ease: "power2.inOut" },
|
||||
0,
|
||||
);
|
||||
// Phase 2: enrichment starts at OUTLINE_DUR
|
||||
```
|
||||
|
||||
### Per-icon entry stagger
|
||||
|
||||
For a row of icons all animating, stagger their entries. Each icon's enrichment starts as it fades in, not synchronized — feels organic.
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **❗ For rotation around an explicit point inside SVG, use the SVG `transform` attribute, NOT CSS transform** — `el.setAttribute('transform', `rotate(${deg} ${cx} ${cy})`)`. The CSS combination `transform: rotate(...)` + `transform-origin: 60px 60px` + `transform-box: fill-box` interprets the origin in the element's OWN bbox-local coordinates, NOT in viewBox coordinates. For a thin `<line>` (whose bbox is the line's narrow envelope), `60 60` in bbox-local refers to a point OUTSIDE the line, so the hand flies along an off-center arc instead of rotating in place. Same trap for small inner shapes (rec-dot circle whose bbox is the small circle, not the full viewBox).
|
||||
- **For scaling around a center point inside SVG**, use `el.setAttribute('transform', `translate(${cx} ${cy}) scale(${s}) translate(-${cx} -${cy})`)`. Same reason — avoids the CSS bbox-local origin trap.
|
||||
- **Run continuous animations inside the timeline** — never CSS `@keyframes` or `requestAnimationFrame`. Both desync from HF's frame-by-frame seek.
|
||||
- **Amplitudes subtle** — icons are decorative, not headlines. Pulse scale within the ranges above; rotation speeds calibrated against composition length, not absolute time.
|
||||
- **Multiple parts of the same icon at different phases** — clock minute vs second hand at different speeds, ring vs dot pulse offset by π/2. Pure-sync looks mechanical; phase-offset looks alive.
|
||||
- **❗ Climax dwell ≥ 1 s** — if the enrichment is the headline beat, the composition must continue ≥ 1 s after the most dramatic moment.
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Timeline must be paused**: `gsap.timeline({ paused: true })`
|
||||
- **Registry key = `data-composition-id`**
|
||||
- **No CSS `animation`** on SVG children — must be timeline-driven
|
||||
- **`transform-origin` matters per child** — set explicitly per animated element
|
||||
- **`stroke-linecap: round`** on flowing/dashed lines for clean dash edges
|
||||
- **Target SVG children by id** — `document.getElementById` is fine; selector chains into `<svg>` work the same as HTML
|
||||
|
||||
## Combinations
|
||||
|
||||
- [svg-path-draw.md](svg-path-draw.md) — outline draws first, enrichment activates second
|
||||
- [orbit-3d-entry.md](orbit-3d-entry.md) — orbiting items are enriched icons (clock orbits a brand label)
|
||||
- [sine-wave-loop.md](sine-wave-loop.md) — entire icon floats while internal parts animate
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — onUpdate writes transform/opacity per SVG child
|
||||
- `/hyperframes-core` — composition wiring
|
||||
- `/hyperframes-cli` — `hyperframes lint`
|
||||
@@ -0,0 +1,274 @@
|
||||
---
|
||||
name: svg-path-draw
|
||||
description: Animate SVG paths drawing progressively using stroke-dasharray and stroke-dashoffset.
|
||||
metadata:
|
||||
tags: svg, stroke, draw, path, reveal, icon, vector
|
||||
---
|
||||
|
||||
# SVG Path Draw
|
||||
|
||||
Reveals an SVG shape by animating its stroke as if a pen were tracing it. The line appears to be drawn in real-time.
|
||||
|
||||
## How It Works
|
||||
|
||||
The trick uses two SVG stroke properties together:
|
||||
|
||||
1. **`stroke-dasharray = <pathLength>`** — sets the dash pattern to a single dash equal to the path's total length, so the entire path is "one dash"
|
||||
2. **`stroke-dashoffset`** — controls how much of the dash is shifted out of view. Start at `pathLength` (entire path is offset out → invisible), animate to `0` (no offset → fully drawn)
|
||||
|
||||
The path length is computed via the DOM API `path.getTotalLength()`.
|
||||
|
||||
## HTML
|
||||
|
||||
```html
|
||||
<div
|
||||
class="scene"
|
||||
id="svg-draw-scene"
|
||||
data-composition-id="svg-draw-scene"
|
||||
data-start="0"
|
||||
data-duration="3"
|
||||
data-track-index="0"
|
||||
>
|
||||
<svg class="logo-mark" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Multi-segment glyph; draw all segments sequentially -->
|
||||
<path id="bar-left" d="M 60 40 L 60 160" />
|
||||
<path id="bar-right" d="M 140 40 L 140 160" />
|
||||
<path id="bar-mid" d="M 60 100 L 140 100" />
|
||||
</svg>
|
||||
<div class="brand-line">{Brand}</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS
|
||||
|
||||
```css
|
||||
.scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: {bgColor};
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.logo-mark {
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
}
|
||||
|
||||
.logo-mark path {
|
||||
fill: none;
|
||||
stroke: {accentColor};
|
||||
stroke-width: 12;
|
||||
stroke-linecap: round; /* soften endpoints */
|
||||
stroke-linejoin: round;
|
||||
/* Initial state: invisible. GSAP fills strokeDasharray + strokeDashoffset
|
||||
based on each path's measured length. */
|
||||
}
|
||||
|
||||
.brand-line {
|
||||
font-family: {font};
|
||||
font-weight: 700;
|
||||
font-size: 48px;
|
||||
color: {textColor};
|
||||
opacity: 0; /* fades in after stroke completes */
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
|
||||
// Named constants — assignments live in the example, not here.
|
||||
// See "How to Choose Values" below for ranges and selection criteria.
|
||||
const SEGMENT_DRAW_DUR; // per-segment stroke duration
|
||||
const FINAL_SEGMENT_DUR; // shorter draw on the last (shorter) segment
|
||||
const SEG_1_START; // first segment start time
|
||||
const SEG_2_START; // second segment start time (overlaps SEG_1 tail)
|
||||
const SEG_3_START; // third segment start time (overlaps SEG_2 tail)
|
||||
const BRAND_FADE_DUR; // wordmark fade-in duration
|
||||
const BRAND_FADE_START; // wordmark fade-in start (after last stroke settles)
|
||||
|
||||
// Measure each path's total length and set up its dash pattern.
|
||||
// getTotalLength() is a real DOM API — its return value is dynamic
|
||||
// measured geometry, NOT a magic number.
|
||||
const paths = document.querySelectorAll(".logo-mark path");
|
||||
paths.forEach((p) => {
|
||||
const len = p.getTotalLength();
|
||||
p.style.strokeDasharray = `${len}`;
|
||||
p.style.strokeDashoffset = `${len}`;
|
||||
});
|
||||
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// Stagger draws across segments — each starts before the previous finishes
|
||||
// so the eye reads continuous motion.
|
||||
tl.to(
|
||||
"#bar-left",
|
||||
{
|
||||
strokeDashoffset: 0,
|
||||
duration: SEGMENT_DRAW_DUR,
|
||||
ease: "power2.out",
|
||||
},
|
||||
SEG_1_START,
|
||||
);
|
||||
tl.to(
|
||||
"#bar-right",
|
||||
{
|
||||
strokeDashoffset: 0,
|
||||
duration: SEGMENT_DRAW_DUR,
|
||||
ease: "power2.out",
|
||||
},
|
||||
SEG_2_START,
|
||||
);
|
||||
tl.to(
|
||||
"#bar-mid",
|
||||
{
|
||||
strokeDashoffset: 0,
|
||||
duration: FINAL_SEGMENT_DUR,
|
||||
ease: "power2.out",
|
||||
},
|
||||
SEG_3_START,
|
||||
);
|
||||
|
||||
// Brand line fades in after the strokes settle
|
||||
tl.to(
|
||||
".brand-line",
|
||||
{
|
||||
opacity: 1,
|
||||
duration: BRAND_FADE_DUR,
|
||||
ease: "power1.out",
|
||||
},
|
||||
BRAND_FADE_START,
|
||||
);
|
||||
|
||||
window.__timelines["svg-draw-scene"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
- **SEGMENT_DRAW_DUR** — per-segment stroke duration
|
||||
- Range: 0.3-0.8s
|
||||
- Effects: low end reads as a fast snap (good for short segments); high end reads as a deliberate pen trace (good for long curves)
|
||||
- Constraints: must be short enough that the total chain (last segment finish) ends before BRAND_FADE_START; longer than ~1s feels sluggish for a logo reveal
|
||||
- Reference: short outline segments use ~0.5s
|
||||
|
||||
- **FINAL_SEGMENT_DUR** — duration of the shortest / final segment
|
||||
- Range: 0.25-0.6s
|
||||
- Effects: should be proportional to segment length — a short connector drawn at SEGMENT_DRAW_DUR appears slower than its longer siblings
|
||||
- Constraints: typically 60-80% of SEGMENT_DRAW_DUR when the segment is visibly shorter than the others
|
||||
- Reference: a mid-bar that is roughly 2/3 the length of the verticals uses ~0.35s
|
||||
|
||||
- **SEG_1_START** — first segment start time
|
||||
- Range: 0-0.4s
|
||||
- Effects: 0 starts immediately on play; >0 gives a brief beat of empty stage before motion
|
||||
- Constraints: should be ≥ 0
|
||||
- Reference: a small lead-in of ~0.2s lets the viewer settle before motion
|
||||
|
||||
- **SEG_2_START** — second segment start time
|
||||
- Range: SEG_1_START + (0.5 \* SEGMENT_DRAW_DUR) to SEG_1_START + SEGMENT_DRAW_DUR
|
||||
- Effects: closer to SEG_1_START + 0.5\*SEGMENT_DRAW_DUR feels rapid/overlapping; closer to SEG_1_START + SEGMENT_DRAW_DUR feels sequential
|
||||
- Constraints: stagger ~70-80% of SEGMENT_DRAW_DUR reads as continuous motion (not 3 isolated animations)
|
||||
- Reference: SEG_1_START + ~0.25s (about half of SEGMENT_DRAW_DUR)
|
||||
|
||||
- **SEG_3_START** — third segment start time
|
||||
- Range: SEG_2_START + (0.5 \* SEGMENT_DRAW_DUR) to SEG_2_START + SEGMENT_DRAW_DUR
|
||||
- Effects: same as SEG_2_START — controls perceived rhythm
|
||||
- Constraints: should preserve the same stagger ratio used between SEG_1 and SEG_2
|
||||
- Reference: SEG_2_START + ~0.4s
|
||||
|
||||
- **BRAND_FADE_DUR** — wordmark fade-in duration
|
||||
- Range: 0.3-0.8s
|
||||
- Effects: low end snaps in (urgent); high end glides in (premium / branded)
|
||||
- Constraints: must finish before the composition's `data-duration` ends
|
||||
- Reference: a calm logo lockup uses ~0.5s
|
||||
|
||||
- **BRAND_FADE_START** — wordmark fade-in start time
|
||||
- Range: max(SEG_3_START + FINAL_SEGMENT_DUR, …) to that value + 0.4s
|
||||
- Effects: starting exactly at last stroke end feels tightly chained; adding a small beat gives the strokes a moment to "settle" before the wordmark joins
|
||||
- Constraints: MUST be ≥ SEG_3_START + FINAL_SEGMENT_DUR (otherwise wordmark appears during the draw and competes with it)
|
||||
- Reference: SEG_3_START + FINAL_SEGMENT_DUR + ~0.2s
|
||||
|
||||
Ease families used here are discrete choices, not tunable scalars:
|
||||
|
||||
- **stroke draws** use `power2.out` — gentle deceleration mimics a hand lifting at end of stroke. Do NOT use `back.out` or `elastic.out` (pens don't bounce).
|
||||
- **brand fade** uses `power1.out` — soft tail on an opacity tween.
|
||||
- For a constant-speed "real pen" tracing feel, swap to `none` (see Variations).
|
||||
|
||||
## Variations
|
||||
|
||||
### Rotation start point (start from top instead of 3 o'clock)
|
||||
|
||||
By default, `<circle>` and `<rect>` start their stroke at 3 o'clock. Rotate the element to start from top:
|
||||
|
||||
```html
|
||||
<circle
|
||||
cx="100"
|
||||
cy="100"
|
||||
r="60"
|
||||
id="ring"
|
||||
style="transform-origin: 100px 100px; transform: rotate(-90deg);"
|
||||
/>
|
||||
```
|
||||
|
||||
### Linear (constant-speed) draw
|
||||
|
||||
Use `ease: 'none'` for steady-rate drawing (like an actual pen tracing):
|
||||
|
||||
```js
|
||||
tl.to("#path", { strokeDashoffset: 0, duration: SEGMENT_DRAW_DUR, ease: "none" }, SEG_1_START);
|
||||
```
|
||||
|
||||
### Draw then fill
|
||||
|
||||
For SVG shapes that have a fill color, animate fill opacity to come in AFTER the stroke completes:
|
||||
|
||||
```js
|
||||
tl.to(
|
||||
"#path",
|
||||
{ strokeDashoffset: 0, duration: SEGMENT_DRAW_DUR, ease: "power2.out" },
|
||||
SEG_1_START,
|
||||
);
|
||||
tl.to(
|
||||
"#path",
|
||||
{ fillOpacity: 1, duration: FILL_FADE_DUR, ease: "power1.out" },
|
||||
SEG_1_START + SEGMENT_DRAW_DUR,
|
||||
);
|
||||
```
|
||||
|
||||
Requires `fill-opacity: 0` initially and a real `fill` color in CSS.
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Set `strokeDasharray` to the path's `getTotalLength()` value**, not an arbitrary number — guessing means stroke will animate but not match the geometry
|
||||
- **Start `strokeDashoffset` at the same length**, animate down to `0`
|
||||
- **Measure inside the timeline setup, not at module top** — SVG may not be rendered when module code runs in some environments. In HF runtime this works at top because SVG is inline, but be safe
|
||||
- **`stroke-linecap: round`** for softer endpoints (less abrupt finish)
|
||||
- **For sequential multi-path draws, stagger by ~70-80% of the previous segment's duration** — eye reads it as continuous motion, not N separate animations
|
||||
- **Don't pair with `back.out` or `elastic.out`** — bouncing strokes feel wrong (the pen wouldn't bounce)
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **`fill: none` in CSS for outline-only draws** — otherwise the fill area appears immediately and ruins the reveal
|
||||
- **Path length is measured in the browser**: requires SVG to be in the DOM. HF inline SVG is fine; loaded `<image>` SVGs may not be
|
||||
- **Timeline must be paused**: `gsap.timeline({ paused: true })`
|
||||
- **Registry key = `data-composition-id`**
|
||||
- **Works on**: `<path>`, `<circle>`, `<rect>`, `<line>`, `<polyline>`, `<polygon>`, `<ellipse>` (anything with a stroke)
|
||||
- **For complex paths**, if `getTotalLength()` looks wrong, overestimate `strokeDasharray` slightly (e.g. `len * 1.05`) — too large is invisible during animation start (no visible gap), too small clips the end
|
||||
|
||||
## Combinations
|
||||
|
||||
- [counting-dynamic-scale.md](counting-dynamic-scale.md) — pair: stroke draws an icon while a number counts up beside it
|
||||
- [hacker-flip-3d.md](hacker-flip-3d.md) — pair: SVG logo draws, then a hacker-flipped wordmark reveals under it
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — timeline + stroke property tween
|
||||
- `/hyperframes-core` — composition wiring
|
||||
- `/hyperframes-cli` — `hyperframes lint`
|
||||
@@ -0,0 +1,239 @@
|
||||
---
|
||||
name: vertical-spring-ticker
|
||||
description: Slot-machine style vertical scrolling using additive spring physics within a masked container — each spring contributes one "step" of scroll.
|
||||
metadata:
|
||||
tags: text, ticker, spring, scroll, vertical, slot-machine, sequence
|
||||
---
|
||||
|
||||
# Vertical Spring Ticker (Slot Machine)
|
||||
|
||||
Multiple spring tweens are ADDED TOGETHER to produce total Y translation. Each spring contributes one discrete "step." The combined motion has snappy distinct moves with natural settling — instead of a single linear scroll, you get the slot-machine "click click click" rhythm.
|
||||
|
||||
## How It Works
|
||||
|
||||
Container has fixed height `ITEM_HEIGHT`, `overflow: hidden`. Inside is a vertical stack of items, each also `ITEM_HEIGHT` tall. The translate of the inner stack is computed as:
|
||||
|
||||
```
|
||||
translateY = -ITEM_HEIGHT * sum(spring_i.progress for each spring)
|
||||
```
|
||||
|
||||
Each spring fires at a different time, settles, then the next fires. When summed, the stack snaps forward step-by-step. The "spring" easing gives each step a tiny overshoot/settle that distinguishes it from a linear marquee.
|
||||
|
||||
## HTML
|
||||
|
||||
```html
|
||||
<div
|
||||
class="scene"
|
||||
id="ticker-scene"
|
||||
data-composition-id="ticker-scene"
|
||||
data-start="0"
|
||||
data-duration="5"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div class="stack">
|
||||
<div class="eyebrow">{eyebrow}</div>
|
||||
<div class="ticker" id="ticker">
|
||||
<div class="stack-inner" id="stack-inner">
|
||||
<!-- One .item per state the ticker rolls through.
|
||||
The example file lists concrete labels; for the rule, treat
|
||||
these as positional slots ({item0} … {itemN}). -->
|
||||
<div class="item">{item0}</div>
|
||||
<div class="item">{item1}</div>
|
||||
<div class="item">{item2}</div>
|
||||
<div class="item">{item3}</div>
|
||||
<div class="item">{itemN}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="brand">{footerLine}</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS
|
||||
|
||||
```css
|
||||
.scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: {bgColor};
|
||||
font-family: {font};
|
||||
}
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: STACK_GAP;
|
||||
}
|
||||
.eyebrow {
|
||||
font-size: EYEBROW_FONT_SIZE;
|
||||
font-weight: 800;
|
||||
letter-spacing: 14px;
|
||||
text-transform: uppercase;
|
||||
color: {accentColor};
|
||||
}
|
||||
/* MANDATORY: container height matches the per-item height exactly */
|
||||
.ticker {
|
||||
width: TICKER_WIDTH;
|
||||
height: ITEM_HEIGHT; /* MUST match .item height */
|
||||
overflow: hidden;
|
||||
border-top: 2px solid {dividerColor};
|
||||
border-bottom: 2px solid {dividerColor};
|
||||
position: relative;
|
||||
}
|
||||
.stack-inner {
|
||||
display: flex;
|
||||
flex-direction: column; /* MANDATORY for vertical ticker */
|
||||
will-change: transform;
|
||||
}
|
||||
.item {
|
||||
height: ITEM_HEIGHT; /* MUST equal .ticker height */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: ITEM_FONT_SIZE;
|
||||
font-weight: 900;
|
||||
letter-spacing: 8px;
|
||||
text-transform: uppercase;
|
||||
color: {textColor};
|
||||
/* font-variant-numeric: tabular-nums; — for numeric tickers */
|
||||
}
|
||||
.brand {
|
||||
font-size: BRAND_FONT_SIZE;
|
||||
font-weight: 800;
|
||||
letter-spacing: 10px;
|
||||
color: {accentColor};
|
||||
text-transform: uppercase;
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
const innerEl = document.getElementById("stack-inner");
|
||||
|
||||
// Each spring object holds a 0→1 progress; they accumulate to a step-counter.
|
||||
// Sum * -ITEM_HEIGHT becomes translateY.
|
||||
const springs = Array.from({ length: STEPS }, () => ({ p: 0 }));
|
||||
|
||||
function applyTransform() {
|
||||
const sumP = springs.reduce((acc, s) => acc + s.p, 0);
|
||||
innerEl.style.transform = `translateY(${-sumP * ITEM_HEIGHT}px)`;
|
||||
}
|
||||
applyTransform(); // initial state
|
||||
|
||||
// Fire each spring sequentially with overlap — each one snaps in one step
|
||||
springs.forEach((spring, i) => {
|
||||
tl.to(
|
||||
spring,
|
||||
{
|
||||
p: 1,
|
||||
duration: STEP_DUR,
|
||||
ease: `back.out(${BOUNCE_FACTOR})`,
|
||||
onUpdate: applyTransform,
|
||||
},
|
||||
STEP_START + i * STEP_SPACING,
|
||||
);
|
||||
});
|
||||
|
||||
// Footer reveals after the ticker settles on the final item.
|
||||
tl.from(
|
||||
".brand",
|
||||
{ opacity: 0, y: BRAND_Y, duration: BRAND_FADE_DUR, ease: "power3.out" },
|
||||
STEP_START + STEPS * STEP_SPACING + BRAND_DELAY,
|
||||
);
|
||||
|
||||
window.__timelines["ticker-scene"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
- **ITEM_HEIGHT** — px height of each ticker slot AND the masked window.
|
||||
- Range: ~`ITEM_FONT_SIZE × 1.25`; the line must hold capital descenders without clipping
|
||||
- Constraints: **`.ticker` height MUST equal `.item` height** exactly — mismatched values cause partial items to peek above/below the mask
|
||||
- Reference: ../../examples/proof-logo-chain.html uses `204px`
|
||||
- **TICKER_WIDTH** — px width of the masked window.
|
||||
- Range: wide enough to hold the longest item without ellipsis; typically 30-60% of viewport width
|
||||
- **STEPS** — number of additive springs (number of state transitions, not number of items).
|
||||
- Range: typically 1-4; each step = one "click" in the slot-machine cadence
|
||||
- Constraints: `STEPS ≤ itemCount − 1` (you can only roll as far as there are items below the visible one)
|
||||
- Reference: ../../examples/proof-logo-chain.html uses `1` (single roll between two states)
|
||||
- **STEP_DUR** — duration of each spring tween.
|
||||
- Range: 0.3-0.7s; under 0.3 the overshoot is invisible, over 0.7 the click reads as a slide
|
||||
- Reference: ../../examples/proof-logo-chain.html uses `0.45s`
|
||||
- **STEP_SPACING** — seconds between consecutive springs' start times.
|
||||
- Range: 0.3-0.5s; closer and the steps blur together (looks like linear scroll), further and the ticker feels lazy
|
||||
- Constraints: `STEP_SPACING ≤ STEP_DUR` so the previous step is still settling when the next fires (this is what makes them "additive")
|
||||
- **STEP_START** — when the first spring fires.
|
||||
- Range: 0+; gate behind any preceding beat
|
||||
- **BOUNCE_FACTOR** — `back.out(BOUNCE_FACTOR)` overshoot strength per step.
|
||||
- Range: 1.4 (gentle click) → 2.0 (firm click) → 2.5+ (cartoony spin-and-land for a climax step)
|
||||
- Effects: low end reads as polished UI, high end reads as casino / game show
|
||||
- **BRAND_DELAY** — gap after the final step before the footer line reveals, in seconds.
|
||||
- Range: 0.2-0.5s; lets the final overshoot settle before the next element competes for attention
|
||||
- **BRAND_FADE_DUR** — footer fade-in duration.
|
||||
- Range: 0.4-0.7s
|
||||
- **BRAND_Y** — initial vertical offset of the footer before fade-up (in px).
|
||||
- Range: 8-24 px; bigger feels "punched in," smaller feels gentle
|
||||
- **EYEBROW_FONT_SIZE / ITEM_FONT_SIZE / BRAND_FONT_SIZE / STACK_GAP** — typographic + layout scaling.
|
||||
- Constraints: items are the focal beat, sized 4-8× larger than eyebrow/footer
|
||||
- **{bgColor} / {accentColor} / {textColor} / {dividerColor}** — semantic color tokens; accent reserved for the eyebrow and footer so the ticker items stay neutral.
|
||||
- **{font}** — base typography stack. For numeric tickers add `font-variant-numeric: tabular-nums` so digit widths stay constant.
|
||||
|
||||
## Variations
|
||||
|
||||
### Numeric ticker (price / counter rolling)
|
||||
|
||||
Replace text items with the digit sequence and use the same spring-step pattern per decimal position (units, tens, hundreds...). Add `font-variant-numeric: tabular-nums` for digit-width stability.
|
||||
|
||||
### Reverse direction (counting down)
|
||||
|
||||
Swap the sign on the translate: `transform: translateY(${sumP * ITEM_HEIGHT}px)` and arrange items in reverse order. Reads as a countdown.
|
||||
|
||||
### Continuous infinite ticker (no settling)
|
||||
|
||||
Loop forever (e.g. news ticker) — use linear ease on a single long tween, duplicate the items list, reset when translation exceeds total height. NOT this rule — see [sine-wave-loop](sine-wave-loop.md) pattern for continuous motion vs this rule's discrete-step semantics.
|
||||
|
||||
### Pause between groups
|
||||
|
||||
For dramatic "spin then land" feel, group several fast spring steps (`STEP_SPACING` small) + a long `BRAND_DELAY`-style pause + a final dramatic step with bigger `BOUNCE_FACTOR`. The pause is where the eye locks in.
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **Container height MUST equal item height** — otherwise items don't snap cleanly into the visible window. If container is 200px and items are 220px, every step shows a partial item edge above/below.
|
||||
- **`overflow: hidden` on container, NOT on inner stack** — the mask is the window; the stack inside is free to extend below.
|
||||
- **`flex-direction: column` on inner stack** — required for vertical stacking; row would make items horizontal.
|
||||
- **Step spacing tighter than step duration** — overlap is what makes the springs additive and gives the "click click" cadence; non-overlapping steps read as a linear scroll.
|
||||
- **`back.out` per step** — the overshoot is what makes each step feel like a "click." Linear ease or out-only ease loses the slot-machine feel.
|
||||
- **Sum the springs in onUpdate, don't tween the final position directly** — this is the "additive" trick; each spring contributes its OWN snap, which is the slot-machine pacing.
|
||||
- **❗ Don't update items via `innerHTML` between steps** — the ticker moves the SAME items via translate; replacing content makes the previous item visible AS the new one (broken illusion).
|
||||
- **❗ Climax dwell ≥1s after final step** — see SKILL universal constraints.
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Timeline must be paused**: `gsap.timeline({ paused: true })`
|
||||
- **Registry key = `data-composition-id`**
|
||||
- **No CSS `transition`** on stack-inner — competes with the additive transform
|
||||
- **`will-change: transform`** on stack-inner — many small transform updates per second
|
||||
- **All items same height (pixel-exact)** — mismatched heights cause cumulative drift
|
||||
- **For numeric: `font-variant-numeric: tabular-nums`** — variable digit widths break alignment
|
||||
|
||||
## Combinations
|
||||
|
||||
- [reactive-displacement.md](reactive-displacement.md) — ticker is "pushed" by an incoming element
|
||||
- [scale-swap-transition.md](scale-swap-transition.md) — ticker scales out after settling on final state, scaled-in subtitle replaces it
|
||||
- [press-release-spring.md](press-release-spring.md) — button press TRIGGERS the ticker spin
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — additive spring tweens via shared onUpdate
|
||||
- `/hyperframes-core` — composition wiring
|
||||
- `/hyperframes-cli` — `hyperframes lint`
|
||||
@@ -0,0 +1,349 @@
|
||||
---
|
||||
name: viewport-change
|
||||
description: Virtual camera — simulate zoom / pan / focus-lock by transforming a wrapper around all scene content. Camera moves right → world translates left.
|
||||
metadata:
|
||||
tags: viewport, camera, zoom, pan, focus-lock, virtual-camera
|
||||
---
|
||||
|
||||
# Viewport Change (Virtual Camera)
|
||||
|
||||
Simulates camera effects (zoom / pan / focus-lock on a moving element) by transforming a wrapper around ALL scene content. The "world" moves opposite to the perceived camera. Distinct from [multi-phase-camera](multi-phase-camera.md) (which is 2-3 discrete phases + drift) — viewport-change is a single continuous zoom/pan, often used for focus-lock following a moving element.
|
||||
|
||||
## How It Works
|
||||
|
||||
Camera intent → world transform:
|
||||
|
||||
- Camera **pans right** → world `translateX(-distance)`
|
||||
- Camera **zooms in** → world `scale(>1)`
|
||||
- Camera **follows element X** → world `translateX(viewportCenter - elementWorldX)` updated per-frame
|
||||
|
||||
The wrapper holds the camera transform; the elements inside are positioned in "world space" unchanged.
|
||||
|
||||
**Single-element composite transform (this rule's form).** Both scale and translate live on ONE wrapper as `translate(x, y) scale(S)`. CSS applies scale FIRST, then translate (right-to-left matrix composition), so a point at world offset `(ox, oy)` lands on screen at `(S × ox + x, S × oy + y)`. To map the target to viewport center:
|
||||
|
||||
```
|
||||
T = -offset × S
|
||||
```
|
||||
|
||||
This is **different from [coordinate-target-zoom](coordinate-target-zoom.md)**, which uses two nested wrappers (outer scales, inner translates) and derives `T = -offset` (independent of S). Use this rule's single-wrapper form when you want one source of truth for camera state (`cam.scale`, `cam.x`, `cam.y`) updated via `onUpdate`; use nested wrappers when scale and translate can tween independently with shared ease.
|
||||
|
||||
## HTML
|
||||
|
||||
```html
|
||||
<div
|
||||
class="scene"
|
||||
id="viewport-scene"
|
||||
data-composition-id="viewport-scene"
|
||||
data-start="0"
|
||||
data-duration="5"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div class="world" id="world">
|
||||
<div class="content">
|
||||
<div class="hero" id="hero">{Brand}</div>
|
||||
<div class="tagline">{tagline}</div>
|
||||
<div class="cta-row">
|
||||
<div class="cta" id="cta">{ctaUrl}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## CSS
|
||||
|
||||
```css
|
||||
.scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: {bgGradient};
|
||||
font-family: {font};
|
||||
}
|
||||
.world {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transform-origin: 50% 50%;
|
||||
will-change: transform;
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: CONTENT_GAP;
|
||||
text-align: center;
|
||||
}
|
||||
.hero {
|
||||
font-size: HERO_FONT_SIZE;
|
||||
font-weight: 900;
|
||||
letter-spacing: HERO_LETTER_SPACING;
|
||||
text-transform: uppercase;
|
||||
color: {textColor};
|
||||
}
|
||||
.tagline {
|
||||
font-size: TAGLINE_FONT_SIZE;
|
||||
font-weight: 600;
|
||||
color: {labelColor};
|
||||
}
|
||||
.cta {
|
||||
display: inline-block;
|
||||
padding: CTA_PADDING_Y CTA_PADDING_X;
|
||||
font-family: {monoFont};
|
||||
font-size: CTA_FONT_SIZE;
|
||||
font-weight: 700;
|
||||
letter-spacing: CTA_LETTER_SPACING;
|
||||
color: {accentColor};
|
||||
text-transform: uppercase;
|
||||
background: {ctaBg};
|
||||
border: 1px solid {ctaBorder};
|
||||
border-radius: CTA_BORDER_RADIUS;
|
||||
}
|
||||
```
|
||||
|
||||
## GSAP Timeline
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
const world = document.getElementById("world");
|
||||
|
||||
// Camera state — single source of truth. World transform is composed from
|
||||
// this object inside applyCamera() so the transform string order is stable.
|
||||
const cam = { scale: 1, x: 0, y: 0 };
|
||||
|
||||
function applyCamera() {
|
||||
world.style.transform = `translate(${cam.x}px, ${cam.y}px) scale(${cam.scale})`;
|
||||
}
|
||||
applyCamera();
|
||||
|
||||
// Phase 1 — content reveal at neutral camera
|
||||
tl.from(".hero", { opacity: 0, y: HERO_Y, duration: HERO_DUR, ease: "power3.out" }, HERO_START);
|
||||
tl.from(
|
||||
".tagline",
|
||||
{ opacity: 0, y: TAGLINE_Y, duration: TAGLINE_DUR, ease: "power3.out" },
|
||||
TAGLINE_START,
|
||||
);
|
||||
|
||||
// Phase 2 — zoom in on CTA (single-element composite transform)
|
||||
// CSS applies scale FIRST then translate: world point (ox, oy) lands at
|
||||
// (S × ox + x, S × oy + y). Solve S × offset + T = 0 → T = -offset × S.
|
||||
// This is DIFFERENT from coordinate-target-zoom (nested wrappers, T = -offset).
|
||||
const counterY = -TARGET_OFFSET_Y * TARGET_SCALE;
|
||||
|
||||
tl.to(
|
||||
cam,
|
||||
{
|
||||
scale: TARGET_SCALE,
|
||||
y: counterY,
|
||||
duration: ZOOM_DUR,
|
||||
ease: "power3.inOut",
|
||||
onUpdate: applyCamera,
|
||||
},
|
||||
ZOOM_START,
|
||||
);
|
||||
|
||||
// Phase 3 — CTA reveals/dwells after zoom settles
|
||||
tl.from(
|
||||
"#cta",
|
||||
{
|
||||
opacity: 0,
|
||||
scale: CTA_REVEAL_SCALE,
|
||||
duration: CTA_REVEAL_DUR,
|
||||
ease: `back.out(${BOUNCE_FACTOR})`,
|
||||
},
|
||||
CTA_REVEAL_START,
|
||||
);
|
||||
|
||||
window.__timelines["viewport-scene"] = tl;
|
||||
</script>
|
||||
```
|
||||
|
||||
## Scale Value Guide
|
||||
|
||||
| Effect | Scale | Feel |
|
||||
| ----------- | ----------- | ----------------------------------- |
|
||||
| Subtle | 1.02 - 1.05 | Barely perceptible — "professional" |
|
||||
| Medium | 1.05 - 1.15 | "Ta-da" emphasis |
|
||||
| Noticeable | 1.15 - 1.30 | Focus on region |
|
||||
| Dramatic | 1.5 - 2.5 | Element fills screen |
|
||||
| Full-screen | 3.0+ | Element covers viewport |
|
||||
|
||||
| Perception threshold | Result |
|
||||
| -------------------- | -------------------- |
|
||||
| < 5% | Imperceptible |
|
||||
| 10-15% | Comfortable emphasis |
|
||||
| > 30% | Cinematic / dramatic |
|
||||
|
||||
## Variations
|
||||
|
||||
### Focus-lock (camera follows moving cursor/character)
|
||||
|
||||
For an element moving across the world, keep it at fixed screen X. Compute world offset per-frame:
|
||||
|
||||
```js
|
||||
const focusEl = document.querySelector(".moving-cursor");
|
||||
const targetScreenX = VIEWPORT_WIDTH * FOCUS_SCREEN_X_FRAC;
|
||||
const focusUpdate = { p: 0 };
|
||||
tl.to(
|
||||
focusUpdate,
|
||||
{
|
||||
p: 1,
|
||||
duration: FOLLOW_DUR,
|
||||
ease: "power2.inOut",
|
||||
onUpdate: () => {
|
||||
const rect = focusEl.getBoundingClientRect();
|
||||
const focusWorldX = rect.left + rect.width / 2;
|
||||
cam.x = targetScreenX - focusWorldX;
|
||||
applyCamera();
|
||||
},
|
||||
},
|
||||
FOLLOW_START,
|
||||
);
|
||||
```
|
||||
|
||||
### Composite scale (multi-phase)
|
||||
|
||||
Multiply two scale tweens for compound effects:
|
||||
|
||||
```js
|
||||
const scaleUp = { v: 1 };
|
||||
const scaleDown = { v: 1 };
|
||||
function applyCompositeCamera() {
|
||||
cam.scale = scaleUp.v * scaleDown.v;
|
||||
applyCamera();
|
||||
}
|
||||
tl.to(
|
||||
scaleUp,
|
||||
{ v: SCALE_UP_TARGET, duration: SCALE_UP_DUR, onUpdate: applyCompositeCamera },
|
||||
SCALE_UP_START,
|
||||
);
|
||||
tl.to(
|
||||
scaleDown,
|
||||
{ v: SCALE_DOWN_TARGET, duration: SCALE_DOWN_DUR, onUpdate: applyCompositeCamera },
|
||||
SCALE_DOWN_START,
|
||||
);
|
||||
```
|
||||
|
||||
### Camera mode transition (centered → follow)
|
||||
|
||||
Crossfade between two camera modes via a 0→1 weight tween. At weight 0, mode A; at weight 1, mode B; intermediate is interpolated.
|
||||
|
||||
## How to Choose Values
|
||||
|
||||
### Layout (CSS)
|
||||
|
||||
- **CONTENT_GAP** — vertical gap between hero, tagline, and CTA.
|
||||
- Range: 16-48 px
|
||||
- Effects: small → tightly stacked (logo-lockup feel); large → airy, editorial
|
||||
- **HERO_FONT_SIZE / TAGLINE_FONT_SIZE / CTA_FONT_SIZE** — typographic hierarchy.
|
||||
- Range: hero >> tagline > CTA (hero is the brand mark, CTA is the actionable footer)
|
||||
- Constraints: hero must remain readable when scaled DOWN at neutral camera AND when scaled UP during the zoom — pick the size at neutral camera, the zoom only enlarges it
|
||||
- **HERO_LETTER_SPACING / CTA_LETTER_SPACING** — uppercase tracking.
|
||||
- Range: 4-10 px for uppercase display type; 0 for sentence case
|
||||
- **CTA_PADDING_X / CTA_PADDING_Y / CTA_BORDER_RADIUS** — pill geometry around the CTA text.
|
||||
- Constraints: `CTA_BORDER_RADIUS ≥ CTA_FONT_SIZE` to keep the pill ends fully rounded
|
||||
|
||||
### Phase 1 — Content reveal
|
||||
|
||||
- **HERO_START** — when the hero begins fading in.
|
||||
- Range: 0.2-0.5s (small offset for a beat of black before content appears)
|
||||
- **HERO_DUR** — hero fade-up duration.
|
||||
- Range: 0.6-1.2s
|
||||
- **HERO_Y** — initial Y offset of hero before fade-up (in px).
|
||||
- Range: 16-48 px
|
||||
- **TAGLINE_START** — when the tagline begins fading in.
|
||||
- Constraints: `≥ HERO_START + 0.3` (let the hero land first so the eye reads top-down)
|
||||
- **TAGLINE_DUR / TAGLINE_Y** — same shape as hero, typically smaller (`TAGLINE_Y` half of `HERO_Y`).
|
||||
|
||||
### Phase 2 — Zoom
|
||||
|
||||
- **TARGET_OFFSET_Y** — measured Y offset (in px) of the CTA from viewport center at neutral camera.
|
||||
- Constraints: derived from layout, NOT a free parameter. Measure via `getBoundingClientRect()` OR compute from `CONTENT_GAP + (HERO_HEIGHT + TAGLINE_HEIGHT) / 2`. Sign matters — positive = below center.
|
||||
- **TARGET_SCALE** — final magnification of the world.
|
||||
- Range: 1.3× (modest) → 1.6-2.0× (typical CTA zoom) → 3×+ (cinematic)
|
||||
- Constraints: raster source media needs `sourceResolution ≥ rendered × TARGET_SCALE`; text remains crisp at any scale
|
||||
- **ZOOM_START** — when the zoom begins.
|
||||
- Constraints: `≥ TAGLINE_START + TAGLINE_DUR + viewer-scan-time` (give viewer ~0.5s after content lands before camera moves)
|
||||
- **ZOOM_DUR** — duration of the zoom tween.
|
||||
- Range: 1.0-2.0s; under 0.8s feels like a teleport, over 2.5s drags
|
||||
|
||||
### Phase 3 — CTA reveal + dwell
|
||||
|
||||
- **CTA_REVEAL_START** — when the CTA pops in.
|
||||
- Constraints: `≥ ZOOM_START + ZOOM_DUR × 0.9` (start near the end of the zoom so the CTA "lands" with the camera)
|
||||
- **CTA_REVEAL_DUR** — CTA fade-in / pop duration.
|
||||
- Range: 0.4-0.8s
|
||||
- **CTA_REVEAL_SCALE** — initial scale of the CTA before pop.
|
||||
- Range: 0.85-0.95 (sub-1 → grows into place); >1.0 inverts to a shrink-into-place feel
|
||||
- **BOUNCE_FACTOR** — overshoot coefficient for `back.out(${BOUNCE_FACTOR})`.
|
||||
- Range: 1.2-2.5; lower = subtle settle, higher = pronounced overshoot. The ease family (`back.out`) is the choice; this number tunes its intensity.
|
||||
- Reference: ease family options: `back.out` (overshoot then settle), `elastic.out` (oscillation), `power3.out` (clean decel, no overshoot)
|
||||
- **DWELL_DUR** — implicit hold after `CTA_REVEAL_START + CTA_REVEAL_DUR` until `data-duration` ends.
|
||||
- Range: ≥ 1.0s (see "Climax dwell" in Key Principles)
|
||||
|
||||
### Focus-lock variation
|
||||
|
||||
- **VIEWPORT_WIDTH** — composition width in px. Real value (`data-width` on the root); not abstract.
|
||||
- **FOCUS_SCREEN_X_FRAC** — where on screen to lock the focused element.
|
||||
- Range: 0.4-0.7 (rule of thirds positions); 0.5 is dead center
|
||||
- **FOLLOW_START / FOLLOW_DUR** — when the follow-cam engages and for how long.
|
||||
- Constraints: `FOLLOW_DUR` matches the duration the focused element is in motion
|
||||
|
||||
### Composite-scale variation
|
||||
|
||||
- **SCALE_UP_TARGET / SCALE_DOWN_TARGET** — multiplicative factors composed via `cam.scale = scaleUp.v * scaleDown.v`.
|
||||
- Effects: combine a slow push-in (`SCALE_UP_TARGET` ~1.15) with a brief release (`SCALE_DOWN_TARGET` ~0.9) for a breath/punch shape
|
||||
- **SCALE_UP_START / SCALE_UP_DUR / SCALE_DOWN_START / SCALE_DOWN_DUR** — phase timing for each multiplicand.
|
||||
|
||||
### Color tokens
|
||||
|
||||
- **{bgGradient}** — scene background (typically a dark radial vignette so edges fall off as zoom reveals them)
|
||||
- **{textColor}** — hero text; highest contrast against `{bgGradient}`
|
||||
- **{labelColor}** — tagline / secondary copy; one step softer than `{textColor}`
|
||||
- **{accentColor}** — CTA text + border; reserved hue that pops on reveal
|
||||
- **{ctaBg} / {ctaBorder}** — semi-transparent fills derived from `{accentColor}` (typical `rgba` at 10-15% / 35-45% alpha)
|
||||
|
||||
### Font tokens
|
||||
|
||||
- **{font}** — sans-serif body / hero stack (e.g. `"Inter", sans-serif`)
|
||||
- **{monoFont}** — monospace CTA stack (e.g. `"JetBrains Mono", monospace`); reserved for the URL/code-like CTA so it reads as actionable
|
||||
|
||||
## Key Principles
|
||||
|
||||
- **World moves opposite to perceived camera** — pan camera right = `translateX(-x)` on the world wrapper. Get this sign right, otherwise everything moves the wrong way.
|
||||
- **Single-wrapper transform order matters** — `translate(x, y) scale(S)` applies scale first; counter-translate is `T = -offset × S`. Mixing this up with the nested-wrapper form (`T = -offset`) drifts the target off-center as scale changes.
|
||||
- **`overflow: hidden` on `.scene` REQUIRED** — at any non-1.0 scale the world transform reveals edges or pushes content off-frame.
|
||||
- **`transform-origin: 50% 50%`** on the world wrapper — centered scaling is what the math assumes.
|
||||
- **Background on `.scene`, NOT on `.world`** — if background is on the world, transforming the world warps/translates the background.
|
||||
- **Single source of truth via `cam` object + `applyCamera()`** — when scale and translate both change, write them in ONE place. Otherwise the transform string composition order is unpredictable.
|
||||
- **Subtle continuous motion > big sudden zoom** — for a feel-natural product video, use 1.05-1.15× zoom over 2-3s. Big > 1.3× zooms read as dramatic narrative moments, save them.
|
||||
- **Climax dwell >=1s** — after the zoom settles, the comp must continue for >=1s so the viewer can read the focal point.
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Timeline must be paused**: `gsap.timeline({ paused: true })`
|
||||
- **Registry key = `data-composition-id`**
|
||||
- **No CSS `transition` on `.world`** — competes with GSAP
|
||||
- **`will-change: transform`** on `.world`
|
||||
- **`overflow: hidden` on `.scene`**
|
||||
- **`transform-origin: 50% 50%` on `.world`**
|
||||
- **Background on `.scene`** — never on `.world`
|
||||
- **Scale and translate share one `onUpdate`** — both read from `cam` and write the composite transform string together; never split them across tweens that touch `world.style.transform` directly
|
||||
|
||||
## Combinations
|
||||
|
||||
- [multi-phase-camera.md](multi-phase-camera.md) — viewport-change inside one phase of a multi-phase camera
|
||||
- [coordinate-target-zoom.md](coordinate-target-zoom.md) — alternative for off-center zoom (nested wrappers, `T = -offset` form)
|
||||
- [sine-wave-loop.md](sine-wave-loop.md) — idle micro-drift after viewport settles
|
||||
|
||||
## Pairs with HF skills
|
||||
|
||||
- `/hyperframes-animation` — single tween writing composite transform
|
||||
- `/hyperframes-core` — composition wiring
|
||||
- `/hyperframes-cli` — `hyperframes lint`
|
||||
@@ -0,0 +1,606 @@
|
||||
#!/usr/bin/env node
|
||||
// animation-map.mjs — HyperFrames animation map for agents
|
||||
//
|
||||
// Reads every GSAP timeline registered in window.__timelines, enumerates
|
||||
// tweens, samples bboxes at N points per tween, computes flags and
|
||||
// human-readable summaries. Outputs a single animation-map.json.
|
||||
//
|
||||
// Usage:
|
||||
// node skills/hyperframes-animation/scripts/animation-map.mjs <composition-dir> \
|
||||
// [--frames N] [--out <dir>] [--min-duration S] [--width W] [--height H] [--fps N]
|
||||
//
|
||||
// Env:
|
||||
// HYPERFRAMES_SKILL_PKG_VERSION — pin the @hyperframes/producer version used
|
||||
// when bootstrapping (global skill installs cannot infer it; falls back to
|
||||
// @latest with a warning otherwise).
|
||||
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { resolve, join } from "node:path";
|
||||
import { hyperframesPackageSpec, importPackagesOrBootstrap } from "./package-loader.mjs";
|
||||
|
||||
const {
|
||||
createFileServer,
|
||||
createCaptureSession,
|
||||
initializeSession,
|
||||
closeCaptureSession,
|
||||
getCompositionDuration,
|
||||
} = (
|
||||
await importPackagesOrBootstrap(["@hyperframes/producer"], {
|
||||
npmPackages: [hyperframesPackageSpec("@hyperframes/producer")],
|
||||
})
|
||||
)["@hyperframes/producer"];
|
||||
|
||||
// ─── CLI ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (!args.composition) die("missing <composition-dir>");
|
||||
|
||||
const FRAMES = Number(args.frames ?? 6);
|
||||
const OUT_DIR = resolve(args.out ?? ".hyperframes/anim-map");
|
||||
const MIN_DUR = Number(args["min-duration"] ?? 0.15);
|
||||
const WIDTH = Number(args.width ?? 1920);
|
||||
const HEIGHT = Number(args.height ?? 1080);
|
||||
const FPS = Number(args.fps ?? 30);
|
||||
const COMP_DIR = resolve(args.composition);
|
||||
|
||||
await mkdir(OUT_DIR, { recursive: true });
|
||||
|
||||
// ─── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const server = await createFileServer({ projectDir: COMP_DIR, port: 0 });
|
||||
const session = await createCaptureSession(
|
||||
server.url,
|
||||
OUT_DIR,
|
||||
{ width: WIDTH, height: HEIGHT, fps: FPS, format: "png" },
|
||||
null,
|
||||
);
|
||||
await initializeSession(session);
|
||||
|
||||
try {
|
||||
const duration = await getCompositionDuration(session);
|
||||
const tweens = await enumerateTweens(session);
|
||||
const kept = tweens.filter((tw) => tw.end - tw.start >= MIN_DUR);
|
||||
|
||||
const report = {
|
||||
composition: COMP_DIR,
|
||||
duration,
|
||||
totalTweens: tweens.length,
|
||||
mappedTweens: kept.length,
|
||||
skippedMicroTweens: tweens.length - kept.length,
|
||||
tweens: [],
|
||||
};
|
||||
|
||||
for (let i = 0; i < kept.length; i++) {
|
||||
const tw = kept[i];
|
||||
const times = Array.from(
|
||||
{ length: FRAMES },
|
||||
(_, k) => +(tw.start + ((k + 0.5) / FRAMES) * (tw.end - tw.start)).toFixed(3),
|
||||
);
|
||||
|
||||
const bboxes = [];
|
||||
for (const t of times) {
|
||||
await seekTo(session, t);
|
||||
const bbox = await measureTarget(session, tw.selectorHint);
|
||||
bboxes.push({ t, ...bbox });
|
||||
}
|
||||
|
||||
const animProps = tw.props.filter(
|
||||
(p) => !["parent", "overwrite", "immediateRender", "startAt", "runBackwards"].includes(p),
|
||||
);
|
||||
const flags = computeFlags(tw, bboxes, { width: WIDTH, height: HEIGHT });
|
||||
const summary = describeTween(tw, animProps, bboxes, flags);
|
||||
|
||||
report.tweens.push({
|
||||
index: i + 1,
|
||||
selector: tw.selectorHint,
|
||||
targets: tw.targetCount,
|
||||
props: animProps,
|
||||
start: +tw.start.toFixed(3),
|
||||
end: +tw.end.toFixed(3),
|
||||
duration: +(tw.end - tw.start).toFixed(3),
|
||||
ease: tw.ease,
|
||||
bboxes,
|
||||
flags,
|
||||
summary,
|
||||
});
|
||||
}
|
||||
|
||||
markCollisions(report.tweens);
|
||||
|
||||
for (const tw of report.tweens) {
|
||||
if (tw.flags.includes("collision") && !tw.summary.includes("collision")) {
|
||||
tw.summary += " Overlaps another animated element.";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Composition-level analysis ──
|
||||
report.choreography = buildTimeline(report.tweens, duration);
|
||||
report.density = computeDensity(report.tweens, duration);
|
||||
report.staggers = detectStaggers(report.tweens);
|
||||
report.elements = buildElementLifecycles(report.tweens);
|
||||
report.deadZones = findDeadZones(report.density, duration);
|
||||
report.snapshots = await captureSnapshots(session, report.tweens, duration);
|
||||
|
||||
await writeFile(join(OUT_DIR, "animation-map.json"), JSON.stringify(report, null, 2));
|
||||
|
||||
printSummary(report);
|
||||
} finally {
|
||||
await closeCaptureSession(session).catch(() => {});
|
||||
server.close();
|
||||
}
|
||||
|
||||
// ─── Seek helper ────────────────────────────────────────────────────────────
|
||||
|
||||
async function seekTo(session, t) {
|
||||
await session.page.evaluate((time) => {
|
||||
if (window.__hf && typeof window.__hf.seek === "function") {
|
||||
window.__hf.seek(time);
|
||||
return;
|
||||
}
|
||||
const tls = window.__timelines;
|
||||
if (tls) {
|
||||
for (const tl of Object.values(tls)) {
|
||||
if (typeof tl.seek === "function") tl.seek(time);
|
||||
}
|
||||
}
|
||||
}, t);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
|
||||
// ─── Timeline introspection ──────────────────────────────────────────────────
|
||||
|
||||
async function enumerateTweens(session) {
|
||||
return await session.page.evaluate(() => {
|
||||
const results = [];
|
||||
const registry = window.__timelines || {};
|
||||
|
||||
const selectorOf = (el) => {
|
||||
if (!el || !(el instanceof Element)) return null;
|
||||
if (el.id) return `#${el.id}`;
|
||||
const cls = [...el.classList].slice(0, 2).join(".");
|
||||
return cls ? `${el.tagName.toLowerCase()}.${cls}` : el.tagName.toLowerCase();
|
||||
};
|
||||
|
||||
const walk = (node, parentOffset = 0) => {
|
||||
if (!node) return;
|
||||
if (typeof node.getChildren === "function") {
|
||||
const offset = parentOffset + (node.startTime?.() ?? 0);
|
||||
for (const child of node.getChildren(true, true, true)) {
|
||||
walk(child, offset);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const targets = (node.targets?.() ?? []).filter((t) => t instanceof Element);
|
||||
if (!targets.length) return;
|
||||
const vars = node.vars ?? {};
|
||||
const props = Object.keys(vars).filter(
|
||||
(k) =>
|
||||
![
|
||||
"duration",
|
||||
"ease",
|
||||
"delay",
|
||||
"repeat",
|
||||
"yoyo",
|
||||
"onStart",
|
||||
"onUpdate",
|
||||
"onComplete",
|
||||
"stagger",
|
||||
].includes(k),
|
||||
);
|
||||
const start = parentOffset + (node.startTime?.() ?? 0);
|
||||
const end = start + (node.duration?.() ?? 0);
|
||||
results.push({
|
||||
selectorHint: selectorOf(targets[0]) ?? "(unknown)",
|
||||
targetCount: targets.length,
|
||||
props,
|
||||
start,
|
||||
end,
|
||||
ease: typeof vars.ease === "string" ? vars.ease : (vars.ease?.toString?.() ?? "none"),
|
||||
});
|
||||
};
|
||||
|
||||
for (const tl of Object.values(registry)) walk(tl, 0);
|
||||
results.sort((a, b) => a.start - b.start);
|
||||
return results;
|
||||
});
|
||||
}
|
||||
|
||||
async function measureTarget(session, selector) {
|
||||
return await session.page.evaluate((sel) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return { x: 0, y: 0, w: 0, h: 0, missing: true };
|
||||
const r = el.getBoundingClientRect();
|
||||
const cs = getComputedStyle(el);
|
||||
return {
|
||||
x: Math.round(r.x),
|
||||
y: Math.round(r.y),
|
||||
w: Math.round(r.width),
|
||||
h: Math.round(r.height),
|
||||
opacity: parseFloat(cs.opacity),
|
||||
visible: cs.visibility !== "hidden" && cs.display !== "none",
|
||||
};
|
||||
}, selector);
|
||||
}
|
||||
|
||||
// ─── Tween description (the key output for agents) ──────────────────────────
|
||||
|
||||
function describeTween(tw, props, bboxes, flags) {
|
||||
const dur = (tw.end - tw.start).toFixed(2);
|
||||
const parts = [];
|
||||
|
||||
parts.push(`${tw.selectorHint} animates ${props.join("+")} over ${dur}s (${tw.ease})`);
|
||||
|
||||
// Movement
|
||||
const first = bboxes[0];
|
||||
const last = bboxes[bboxes.length - 1];
|
||||
if (first && last) {
|
||||
const dx = last.x - first.x;
|
||||
const dy = last.y - first.y;
|
||||
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) {
|
||||
const dirs = [];
|
||||
if (Math.abs(dy) > 3) dirs.push(dy < 0 ? `${Math.abs(dy)}px up` : `${Math.abs(dy)}px down`);
|
||||
if (Math.abs(dx) > 3)
|
||||
dirs.push(dx < 0 ? `${Math.abs(dx)}px left` : `${Math.abs(dx)}px right`);
|
||||
parts.push(`moves ${dirs.join(" and ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Opacity
|
||||
if (first && last && first.opacity !== undefined && last.opacity !== undefined) {
|
||||
const o1 = first.opacity;
|
||||
const o2 = last.opacity;
|
||||
if (Math.abs(o2 - o1) > 0.1) {
|
||||
if (o1 < 0.1 && o2 > 0.5) parts.push("fades in");
|
||||
else if (o1 > 0.5 && o2 < 0.1) parts.push("fades out");
|
||||
else parts.push(`opacity ${o1.toFixed(1)}→${o2.toFixed(1)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Scale (from props)
|
||||
if (props.includes("scale") || props.includes("scaleX") || props.includes("scaleY")) {
|
||||
parts.push("scales");
|
||||
}
|
||||
|
||||
// Size changes
|
||||
if (first && last) {
|
||||
const dw = last.w - first.w;
|
||||
const dh = last.h - first.h;
|
||||
if (Math.abs(dw) > 5) parts.push(`width ${first.w}→${last.w}px`);
|
||||
if (Math.abs(dh) > 5) parts.push(`height ${first.h}→${last.h}px`);
|
||||
}
|
||||
|
||||
// Visibility
|
||||
if (first && last && first.visible !== last.visible) {
|
||||
parts.push(last.visible ? "becomes visible" : "becomes hidden");
|
||||
}
|
||||
|
||||
// Final position
|
||||
if (last && !last.missing) {
|
||||
parts.push(`ends at (${last.x}, ${last.y}) ${last.w}×${last.h}px`);
|
||||
}
|
||||
|
||||
// Flags
|
||||
if (flags.length > 0) {
|
||||
parts.push(`FLAGS: ${flags.join(", ")}`);
|
||||
}
|
||||
|
||||
return parts.join(". ") + ".";
|
||||
}
|
||||
|
||||
// ─── Flag computation ───────────────────────────────────────────────────────
|
||||
|
||||
function computeFlags(tw, bboxes, { width, height }) {
|
||||
const flags = [];
|
||||
const dur = tw.end - tw.start;
|
||||
|
||||
if (bboxes.every((b) => b.w === 0 || b.h === 0)) flags.push("degenerate");
|
||||
|
||||
const anyOffscreen = bboxes.some(
|
||||
(b) =>
|
||||
b.x + b.w <= 0 ||
|
||||
b.y + b.h <= 0 ||
|
||||
b.x >= width ||
|
||||
b.y >= height ||
|
||||
b.x < -b.w * 0.5 ||
|
||||
b.y < -b.h * 0.5 ||
|
||||
b.x + b.w > width + b.w * 0.5 ||
|
||||
b.y + b.h > height + b.h * 0.5,
|
||||
);
|
||||
if (anyOffscreen) flags.push("offscreen");
|
||||
|
||||
if (bboxes.every((b) => b.opacity !== undefined && b.opacity < 0.01 && b.visible)) {
|
||||
flags.push("invisible");
|
||||
}
|
||||
|
||||
if (dur < 0.2 && tw.props.some((p) => ["y", "x", "opacity", "scale"].includes(p))) {
|
||||
flags.push("paced-fast");
|
||||
}
|
||||
if (dur > 2.0) flags.push("paced-slow");
|
||||
|
||||
return flags;
|
||||
}
|
||||
|
||||
function markCollisions(tweens) {
|
||||
for (let i = 0; i < tweens.length; i++) {
|
||||
for (let j = i + 1; j < tweens.length; j++) {
|
||||
const a = tweens[i];
|
||||
const b = tweens[j];
|
||||
if (a.end <= b.start || b.end <= a.start) continue;
|
||||
for (const ba of a.bboxes) {
|
||||
const bb = b.bboxes.find((x) => Math.abs(x.t - ba.t) < 0.05);
|
||||
if (!bb) continue;
|
||||
const overlap = rectOverlapArea(ba, bb);
|
||||
const aArea = ba.w * ba.h;
|
||||
if (aArea > 0 && overlap / aArea > 0.3) {
|
||||
if (!a.flags.includes("collision")) a.flags.push("collision");
|
||||
if (!b.flags.includes("collision")) b.flags.push("collision");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rectOverlapArea(a, b) {
|
||||
const x1 = Math.max(a.x, b.x);
|
||||
const y1 = Math.max(a.y, b.y);
|
||||
const x2 = Math.min(a.x + a.w, b.x + b.w);
|
||||
const y2 = Math.min(a.y + a.h, b.y + b.h);
|
||||
return Math.max(0, x2 - x1) * Math.max(0, y2 - y1);
|
||||
}
|
||||
|
||||
// ─── Composition-level analysis ─────────────────────────────────────────────
|
||||
|
||||
function buildTimeline(tweens, duration) {
|
||||
const cols = 60;
|
||||
const lines = [];
|
||||
const secPerCol = duration / cols;
|
||||
|
||||
lines.push("Timeline (" + duration.toFixed(1) + "s, each char ≈ " + secPerCol.toFixed(2) + "s):");
|
||||
lines.push(" " + "0s" + " ".repeat(cols - 8) + duration.toFixed(0) + "s");
|
||||
lines.push(" " + "┼" + "─".repeat(cols - 1) + "┤");
|
||||
|
||||
for (const tw of tweens) {
|
||||
const startCol = Math.floor(tw.start / secPerCol);
|
||||
const endCol = Math.min(cols, Math.ceil(tw.end / secPerCol));
|
||||
const bar =
|
||||
" ".repeat(startCol) +
|
||||
"█".repeat(Math.max(1, endCol - startCol)) +
|
||||
" ".repeat(Math.max(0, cols - endCol));
|
||||
const label = tw.selector + " " + tw.props.join("+");
|
||||
lines.push(" " + bar + " " + label);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function computeDensity(tweens, duration) {
|
||||
const buckets = [];
|
||||
for (let t = 0; t < duration; t += 0.5) {
|
||||
const active = tweens.filter((tw) => tw.start <= t + 0.5 && tw.end >= t);
|
||||
buckets.push({ t: +t.toFixed(1), activeTweens: active.length });
|
||||
}
|
||||
return buckets;
|
||||
}
|
||||
|
||||
function findDeadZones(density, duration) {
|
||||
const zones = [];
|
||||
let zoneStart = null;
|
||||
for (const d of density) {
|
||||
if (d.activeTweens === 0) {
|
||||
if (zoneStart === null) zoneStart = d.t;
|
||||
} else {
|
||||
if (zoneStart !== null) {
|
||||
const zoneEnd = d.t;
|
||||
if (zoneEnd - zoneStart >= 1.0) {
|
||||
zones.push({
|
||||
start: zoneStart,
|
||||
end: zoneEnd,
|
||||
duration: +(zoneEnd - zoneStart).toFixed(1),
|
||||
note:
|
||||
"No animation for " +
|
||||
(zoneEnd - zoneStart).toFixed(1) +
|
||||
"s. Intentional hold or missing entrance?",
|
||||
});
|
||||
}
|
||||
zoneStart = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (zoneStart !== null && duration - zoneStart >= 1.0) {
|
||||
zones.push({
|
||||
start: zoneStart,
|
||||
end: +duration.toFixed(1),
|
||||
duration: +(duration - zoneStart).toFixed(1),
|
||||
note:
|
||||
"No animation for " +
|
||||
(duration - zoneStart).toFixed(1) +
|
||||
"s at end. Final hold or missing outro?",
|
||||
});
|
||||
}
|
||||
return zones;
|
||||
}
|
||||
|
||||
function detectStaggers(tweens) {
|
||||
const groups = [];
|
||||
const used = new Set();
|
||||
|
||||
for (let i = 0; i < tweens.length; i++) {
|
||||
if (used.has(i)) continue;
|
||||
const tw = tweens[i];
|
||||
const group = [tw];
|
||||
used.add(i);
|
||||
|
||||
for (let j = i + 1; j < tweens.length; j++) {
|
||||
if (used.has(j)) continue;
|
||||
const other = tweens[j];
|
||||
const sameProps = tw.props.join(",") === other.props.join(",");
|
||||
const sameDuration = Math.abs(tw.duration - other.duration) < 0.05;
|
||||
const closeInTime = other.start - tw.start < tw.duration * 4;
|
||||
if (sameProps && sameDuration && closeInTime) {
|
||||
group.push(other);
|
||||
used.add(j);
|
||||
}
|
||||
}
|
||||
|
||||
if (group.length >= 3) {
|
||||
const intervals = [];
|
||||
for (let k = 1; k < group.length; k++) {
|
||||
intervals.push(+(group[k].start - group[k - 1].start).toFixed(3));
|
||||
}
|
||||
const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length;
|
||||
const maxDrift = Math.max(...intervals.map((iv) => Math.abs(iv - avgInterval)));
|
||||
const consistent = maxDrift < avgInterval * 0.3;
|
||||
|
||||
groups.push({
|
||||
elements: group.map((g) => g.selector),
|
||||
props: tw.props,
|
||||
count: group.length,
|
||||
intervals,
|
||||
avgInterval: +avgInterval.toFixed(3),
|
||||
consistent,
|
||||
note: consistent
|
||||
? group.length +
|
||||
" elements stagger at " +
|
||||
(avgInterval * 1000).toFixed(0) +
|
||||
"ms intervals"
|
||||
: group.length +
|
||||
" elements stagger with uneven intervals (" +
|
||||
intervals.map((iv) => (iv * 1000).toFixed(0) + "ms").join(", ") +
|
||||
")",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function buildElementLifecycles(tweens) {
|
||||
const elements = {};
|
||||
for (const tw of tweens) {
|
||||
const sel = tw.selector;
|
||||
if (!elements[sel]) {
|
||||
elements[sel] = { firstTween: tw.start, lastTween: tw.end, tweenCount: 0, props: new Set() };
|
||||
}
|
||||
elements[sel].firstTween = Math.min(elements[sel].firstTween, tw.start);
|
||||
elements[sel].lastTween = Math.max(elements[sel].lastTween, tw.end);
|
||||
elements[sel].tweenCount++;
|
||||
tw.props.forEach((p) => elements[sel].props.add(p));
|
||||
}
|
||||
|
||||
const result = {};
|
||||
for (const [sel, data] of Object.entries(elements)) {
|
||||
const lastBbox = findLastBbox(tweens, sel);
|
||||
result[sel] = {
|
||||
firstAppears: +data.firstTween.toFixed(3),
|
||||
lastAnimates: +data.lastTween.toFixed(3),
|
||||
tweenCount: data.tweenCount,
|
||||
props: [...data.props],
|
||||
endsVisible: lastBbox ? lastBbox.opacity > 0.1 && lastBbox.visible : null,
|
||||
finalPosition: lastBbox
|
||||
? { x: lastBbox.x, y: lastBbox.y, w: lastBbox.w, h: lastBbox.h }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function findLastBbox(tweens, selector) {
|
||||
for (let i = tweens.length - 1; i >= 0; i--) {
|
||||
if (tweens[i].selector === selector && tweens[i].bboxes?.length > 0) {
|
||||
return tweens[i].bboxes[tweens[i].bboxes.length - 1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function captureSnapshots(session, tweens, duration) {
|
||||
const times = [0, duration * 0.25, duration * 0.5, duration * 0.75, duration - 0.1];
|
||||
const snapshots = [];
|
||||
|
||||
for (const t of times) {
|
||||
await seekTo(session, t);
|
||||
const visible = await session.page.evaluate(() => {
|
||||
const out = [];
|
||||
const els = document.querySelectorAll("[id]");
|
||||
for (const el of els) {
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.display === "none") continue;
|
||||
const opacity = parseFloat(cs.opacity);
|
||||
if (opacity < 0.01) continue;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width < 1 || rect.height < 1) continue;
|
||||
out.push({
|
||||
id: el.id,
|
||||
x: Math.round(rect.x),
|
||||
y: Math.round(rect.y),
|
||||
w: Math.round(rect.width),
|
||||
h: Math.round(rect.height),
|
||||
opacity: +opacity.toFixed(2),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const activeTweens = tweens
|
||||
.filter((tw) => tw.start <= t && tw.end >= t)
|
||||
.map((tw) => tw.selector);
|
||||
|
||||
snapshots.push({
|
||||
t: +t.toFixed(2),
|
||||
visibleElements: visible.length,
|
||||
animatingNow: activeTweens,
|
||||
elements: visible,
|
||||
});
|
||||
}
|
||||
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
// ─── Output ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function printSummary(report) {
|
||||
console.log(
|
||||
`\nAnimation map: ${report.mappedTweens}/${report.totalTweens} tweens (skipped ${report.skippedMicroTweens} micro-tweens)`,
|
||||
);
|
||||
|
||||
const flagCounts = {};
|
||||
for (const tw of report.tweens) {
|
||||
for (const f of tw.flags) flagCounts[f] = (flagCounts[f] ?? 0) + 1;
|
||||
}
|
||||
if (Object.keys(flagCounts).length > 0) {
|
||||
for (const [f, n] of Object.entries(flagCounts)) console.log(` ${f}: ${n}`);
|
||||
}
|
||||
if (report.staggers?.length > 0) {
|
||||
console.log(` staggers: ${report.staggers.map((s) => s.note).join("; ")}`);
|
||||
}
|
||||
if (report.deadZones?.length > 0) {
|
||||
console.log(
|
||||
` dead zones: ${report.deadZones.map((z) => z.start + "-" + z.end + "s").join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(report.choreography);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = {};
|
||||
let positional = 0;
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a.startsWith("--")) {
|
||||
const k = a.slice(2);
|
||||
const v = argv[i + 1]?.startsWith("--") ? true : argv[++i];
|
||||
out[k] = v;
|
||||
} else if (positional === 0) {
|
||||
out.composition = a;
|
||||
positional++;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function die(msg) {
|
||||
console.error(`animation-map: ${msg}`);
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
// package-loader — bootstrap optional helper packages only when missing, with
|
||||
// defense-in-depth so a malicious or typo'd dependency can't run on install:
|
||||
// • specs are version-pinned (assertPinnedPackageSpecs) — no floating "latest"
|
||||
// • install runs `npm install --ignore-scripts` — package lifecycle scripts
|
||||
// never execute
|
||||
// • `--no-save` into a throwaway tmp dir — the host project is left untouched
|
||||
// • requires an interactive y/N (or an explicit $HYPERFRAMES_SKILL_BOOTSTRAP_DEPS=1)
|
||||
// • npm is spawned with an argv array (no shell) — never a built command string
|
||||
// The `installLine` strings below are DISPLAY ONLY (shown in the prompt / error
|
||||
// text); they are never handed to a shell or executed.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, delimiter, dirname, join, parse, resolve } from "node:path";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const VERSION_OVERRIDE_ENV = "HYPERFRAMES_SKILL_PKG_VERSION";
|
||||
const BOOTSTRAP_ENV = "HYPERFRAMES_SKILL_DEPS_BOOTSTRAPPED";
|
||||
const BOOTSTRAP_CONFIRM_ENV = "HYPERFRAMES_SKILL_BOOTSTRAP_DEPS";
|
||||
const NODE_MODULES_ENV = "HYPERFRAMES_SKILL_NODE_MODULES";
|
||||
|
||||
export async function importPackagesOrBootstrap(packageNames, options = {}) {
|
||||
const entries = new Map();
|
||||
const missing = [];
|
||||
|
||||
for (const packageName of packageNames) {
|
||||
const entry = resolvePackageEntry(packageName);
|
||||
if (entry) entries.set(packageName, entry);
|
||||
else missing.push(packageName);
|
||||
}
|
||||
|
||||
if (missing.length > 0 && !process.env[BOOTSTRAP_ENV]) {
|
||||
const npmPackages = options.npmPackages ?? missing;
|
||||
assertPinnedPackageSpecs(npmPackages);
|
||||
await confirmBootstrap(npmPackages);
|
||||
bootstrapWithNpmInstall(npmPackages);
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
[
|
||||
`Could not resolve required package(s): ${missing.join(", ")}`,
|
||||
"Install them in this project, for example:",
|
||||
` npm install --save-dev ${packageNames.map(shellQuote).join(" ")}`,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
const modules = {};
|
||||
for (const [packageName, entry] of entries) {
|
||||
modules[packageName] = await import(pathToFileURL(entry).href);
|
||||
}
|
||||
return modules;
|
||||
}
|
||||
|
||||
export function hyperframesPackageSpec(packageName) {
|
||||
const override = process.env[VERSION_OVERRIDE_ENV]?.trim();
|
||||
if (override) return `${packageName}@${override}`;
|
||||
|
||||
const version = readBundledHyperframesVersion();
|
||||
if (version) return `${packageName}@${version}`;
|
||||
|
||||
// Global skill installs (e.g. ~/.claude/skills) have no hyperframes package.json
|
||||
// in their ancestor chain, so the bundled version is unknowable. Fall back to
|
||||
// @latest instead of throwing: already-installed packages still import, and a
|
||||
// bootstrap install can still proceed (@latest satisfies the pinned-spec guard).
|
||||
process.stderr.write(
|
||||
[
|
||||
`hyperframes: could not determine the bundled version for ${packageName}; using @latest.`,
|
||||
`Set ${VERSION_OVERRIDE_ENV}=<version> to pin it.`,
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
return `${packageName}@latest`;
|
||||
}
|
||||
|
||||
function resolvePackageEntry(packageName) {
|
||||
const bases = [process.cwd(), HERE, ...envNodeModulesDirs(), ...nodeModulesDirsFromPath()];
|
||||
|
||||
const seen = new Set();
|
||||
for (const base of bases) {
|
||||
const normalized = resolve(base);
|
||||
if (seen.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
|
||||
try {
|
||||
return createRequire(join(normalized, "__hyperframes_skill_loader__.cjs")).resolve(
|
||||
packageName,
|
||||
);
|
||||
} catch {
|
||||
const packageDir = findPackageDir(normalized, packageName);
|
||||
const packageEntry = packageDir ? readPackageEntry(packageDir) : null;
|
||||
if (packageEntry) return packageEntry;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function readBundledHyperframesVersion() {
|
||||
for (const ancestor of ancestors(HERE)) {
|
||||
const directVersion = readPackageVersion(join(ancestor, "package.json"));
|
||||
if (directVersion) return directVersion;
|
||||
|
||||
const monorepoCliVersion = readPackageVersion(
|
||||
join(ancestor, "packages", "cli", "package.json"),
|
||||
);
|
||||
if (monorepoCliVersion) return monorepoCliVersion;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readPackageVersion(packageJsonPath) {
|
||||
try {
|
||||
const manifest = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
||||
if (manifest.name === "hyperframes" || manifest.name === "@hyperframes/cli") {
|
||||
return typeof manifest.version === "string" ? manifest.version : null;
|
||||
}
|
||||
} catch {
|
||||
// Keep searching ancestor package manifests.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function envNodeModulesDirs() {
|
||||
return (process.env[NODE_MODULES_ENV] ?? "").split(delimiter).filter(Boolean);
|
||||
}
|
||||
|
||||
function nodeModulesDirsFromPath() {
|
||||
const dirs = [];
|
||||
for (const entry of (process.env.PATH ?? "").split(delimiter)) {
|
||||
if (!entry.endsWith(`${join("node_modules", ".bin")}`)) continue;
|
||||
dirs.push(dirname(entry));
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
function findPackageDir(base, packageName) {
|
||||
const packageSegments = packageName.split("/");
|
||||
const roots =
|
||||
basename(base) === "node_modules"
|
||||
? [base]
|
||||
: ancestors(base).map((ancestor) => join(ancestor, "node_modules"));
|
||||
|
||||
for (const root of roots) {
|
||||
const packageDir = join(root, ...packageSegments);
|
||||
if (existsSync(join(packageDir, "package.json"))) return packageDir;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readPackageEntry(packageDir) {
|
||||
try {
|
||||
const manifest = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8"));
|
||||
const entry = exportEntry(manifest.exports) ?? manifest.module ?? manifest.main ?? "index.js";
|
||||
const entryPath = join(packageDir, entry);
|
||||
return existsSync(entryPath) ? entryPath : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function exportEntry(exports) {
|
||||
const root =
|
||||
typeof exports === "object" && exports !== null ? (exports["."] ?? exports) : exports;
|
||||
if (typeof root === "string") return root;
|
||||
if (typeof root !== "object" || root === null) return null;
|
||||
if (typeof root.import === "string") return root.import;
|
||||
if (typeof root.default === "string") return root.default;
|
||||
if (typeof root.node === "string") return root.node;
|
||||
if (typeof root.node === "object" && root.node !== null) {
|
||||
return root.node.import ?? root.node.default ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function assertPinnedPackageSpecs(packageSpecs) {
|
||||
const unpinned = packageSpecs.filter((spec) => !hasVersionSpec(spec));
|
||||
if (unpinned.length === 0) return;
|
||||
throw new Error(
|
||||
[
|
||||
`Refusing to bootstrap unpinned package spec(s): ${unpinned.join(", ")}`,
|
||||
"Pass pinned npm package specs, for example:",
|
||||
` ${packageSpecs.map((spec) => (hasVersionSpec(spec) ? spec : `${spec}@<version>`)).join(" ")}`,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
function hasVersionSpec(packageSpec) {
|
||||
if (packageSpec.startsWith("@")) {
|
||||
const slash = packageSpec.indexOf("/");
|
||||
return slash !== -1 && packageSpec.indexOf("@", slash + 1) !== -1;
|
||||
}
|
||||
return packageSpec.includes("@");
|
||||
}
|
||||
|
||||
async function confirmBootstrap(packageSpecs) {
|
||||
if (process.env[BOOTSTRAP_CONFIRM_ENV] === "1") return;
|
||||
|
||||
const installLine = `npm install --ignore-scripts --no-save ${packageSpecs.map(shellQuote).join(" ")}`;
|
||||
if (!process.stdin.isTTY) {
|
||||
throw new Error(
|
||||
[
|
||||
"Required helper package(s) are missing.",
|
||||
"To allow a one-time temporary dependency bootstrap for this run, set:",
|
||||
` ${BOOTSTRAP_CONFIRM_ENV}=1`,
|
||||
"The bootstrap command will be:",
|
||||
` ${installLine}`,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
||||
try {
|
||||
const answer = await rl.question(
|
||||
[
|
||||
"HyperFrames helper package(s) are missing.",
|
||||
`Run a temporary install with lifecycle scripts disabled?`,
|
||||
` ${installLine}`,
|
||||
"Proceed? [y/N] ",
|
||||
].join("\n"),
|
||||
);
|
||||
if (!/^(y|yes)$/i.test(answer.trim())) {
|
||||
throw new Error("Dependency bootstrap cancelled.");
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
function ancestors(start) {
|
||||
const dirs = [];
|
||||
let current = resolve(start);
|
||||
const root = parse(current).root;
|
||||
while (current && current !== root) {
|
||||
dirs.push(current);
|
||||
current = dirname(current);
|
||||
}
|
||||
dirs.push(root);
|
||||
return dirs;
|
||||
}
|
||||
|
||||
function bootstrapWithNpmInstall(packageNames) {
|
||||
const installRoot = mkdtempSync(join(tmpdir(), "hyperframes-skill-deps-"));
|
||||
const installResult = spawnSync(
|
||||
process.platform === "win32" ? "npm.cmd" : "npm",
|
||||
[
|
||||
"install",
|
||||
"--silent",
|
||||
"--no-audit",
|
||||
"--no-fund",
|
||||
"--ignore-scripts",
|
||||
"--no-save",
|
||||
"--prefix",
|
||||
installRoot,
|
||||
...packageNames,
|
||||
],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
|
||||
if (installResult.error) throw installResult.error;
|
||||
if (installResult.status !== 0) {
|
||||
rmSync(installRoot, { recursive: true, force: true });
|
||||
process.exit(installResult.status ?? 1);
|
||||
}
|
||||
|
||||
const args = [...process.argv.slice(1)];
|
||||
const result = spawnSync(process.execPath, args, {
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
[BOOTSTRAP_ENV]: "1",
|
||||
[NODE_MODULES_ENV]: join(installRoot, "node_modules"),
|
||||
},
|
||||
});
|
||||
|
||||
rmSync(installRoot, { recursive: true, force: true });
|
||||
if (result.error) throw result.error;
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
function shellQuote(value) {
|
||||
if (/^[A-Za-z0-9_./:@=-]+$/.test(value)) return value;
|
||||
return `'${value.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ENV = "HYPERFRAMES_SKILL_PKG_VERSION";
|
||||
|
||||
// (a) env override wins — no ancestor lookup, exact version echoed back.
|
||||
test("hyperframesPackageSpec: env override wins", async () => {
|
||||
const prev = process.env[ENV];
|
||||
process.env[ENV] = "9.9.9";
|
||||
try {
|
||||
const { hyperframesPackageSpec } = await import("./package-loader.mjs");
|
||||
assert.equal(hyperframesPackageSpec("@hyperframes/producer"), "@hyperframes/producer@9.9.9");
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env[ENV];
|
||||
else process.env[ENV] = prev;
|
||||
}
|
||||
});
|
||||
|
||||
// (b) resolvable version (in-repo) pins the bundled hyperframes/@hyperframes/cli version.
|
||||
test("hyperframesPackageSpec: resolvable in-repo version pins it", async () => {
|
||||
const prev = process.env[ENV];
|
||||
delete process.env[ENV];
|
||||
try {
|
||||
const { hyperframesPackageSpec } = await import("./package-loader.mjs");
|
||||
const spec = hyperframesPackageSpec("@hyperframes/producer");
|
||||
assert.match(spec, /^@hyperframes\/producer@\d+\.\d+\.\d+/);
|
||||
} finally {
|
||||
if (prev !== undefined) process.env[ENV] = prev;
|
||||
}
|
||||
});
|
||||
|
||||
// (c) unresolvable + no override -> @latest fallback, no throw (global-install case).
|
||||
// Copy the loader into an isolated temp dir whose ancestor chain has no hyperframes
|
||||
// package.json, and run node from there so cwd cannot resolve one either.
|
||||
test("hyperframesPackageSpec: unresolvable falls back to @latest without throwing", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-pkgloader-"));
|
||||
try {
|
||||
copyFileSync(join(HERE, "package-loader.mjs"), join(dir, "package-loader.mjs"));
|
||||
const probe = join(dir, "probe.mjs");
|
||||
writeFileSync(
|
||||
probe,
|
||||
[
|
||||
'import { hyperframesPackageSpec } from "./package-loader.mjs";',
|
||||
'process.stdout.write(hyperframesPackageSpec("@hyperframes/producer"));',
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
const res = spawnSync(process.execPath, [probe], { cwd: dir, encoding: "utf8" });
|
||||
assert.equal(res.status, 0, res.stderr);
|
||||
assert.equal(res.stdout.trim(), "@hyperframes/producer@latest");
|
||||
assert.match(res.stderr, /using @latest/);
|
||||
assert.match(res.stderr, new RegExp(ENV));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,507 @@
|
||||
# Visual Techniques Reference
|
||||
|
||||
13 proven techniques from production HyperFrames videos. Use these in your storyboard and compositions to create visually rich, professional output. Each technique includes a minimal code pattern you can adapt.
|
||||
|
||||
These are NOT advanced — they're standard motion design patterns that every composition should use at least 2-3 of.
|
||||
|
||||
## Contents
|
||||
|
||||
- SVG path drawing
|
||||
- Canvas 2D procedural art
|
||||
- CSS 3D transforms
|
||||
- Per-word kinetic typography
|
||||
- Lottie animation
|
||||
- Video compositing
|
||||
- Character-by-character typing
|
||||
- Variable font axis animation
|
||||
- GSAP MotionPathPlugin
|
||||
- Velocity-matched transitions
|
||||
- Audio-reactive animation
|
||||
- Clip-path reveal masks
|
||||
- WebGL fragment shader art
|
||||
- When to use what
|
||||
|
||||
> **Capturing live HTML/CSS as a GPU texture** (3D rotation + bloom, magnetic warp, shatter, liquid surface, portal, plus GLSL post-processing) is a separate, heavier capability — see `adapters/html-in-canvas-patterns.md`. Use it for 1–3 hero beats per video, not every beat.
|
||||
>
|
||||
> **Easing vocabulary** — the full ease-family palette (character + mood mapping) lives in `adapters/gsap-easing-and-stagger.md`. Every composition should use at least 3 different easings.
|
||||
>
|
||||
> **Named text-animation effects** (24 IDs like `typewriter`, `kinetic-center-build`, `soft-blur-in`) come from the external `animate-text` skill — see `adapters/animate-text.md` for the vocabulary and how to load it.
|
||||
|
||||
---
|
||||
|
||||
## 1. SVG Path Drawing
|
||||
|
||||
A path draws itself in real-time, like someone tracing with a pen. Use for revealing diagrams, arrows, connector lines, or brand marks.
|
||||
|
||||
```html
|
||||
<svg viewBox="0 0 400 200">
|
||||
<path
|
||||
class="draw-path"
|
||||
d="M 50 100 L 200 50 L 350 100"
|
||||
stroke="#c84f1c"
|
||||
stroke-width="4"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<style>
|
||||
.draw-path {
|
||||
stroke-dasharray: 280;
|
||||
stroke-dashoffset: 280;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
tl.to(".draw-path", { strokeDashoffset: 0, duration: 0.7, ease: "power2.out" }, 0.5);
|
||||
</script>
|
||||
```
|
||||
|
||||
Use `path.getTotalLength()` to calculate the dasharray value dynamically.
|
||||
|
||||
---
|
||||
|
||||
## 2. Canvas 2D Procedural Art
|
||||
|
||||
Animated noise, particle fields, data visualizations — anything that evolves frame-by-frame. Drive it with a GSAP proxy.
|
||||
|
||||
```html
|
||||
<canvas id="proc-canvas" width="1920" height="1080"></canvas>
|
||||
<script>
|
||||
var canvas = document.getElementById("proc-canvas");
|
||||
var ctx = canvas.getContext("2d");
|
||||
|
||||
function hash(x, y) {
|
||||
var n = x * 374761393 + y * 668265263;
|
||||
n = (n ^ (n >> 13)) * 1274126177;
|
||||
return ((n ^ (n >> 16)) & 0x7fffffff) / 0x7fffffff;
|
||||
}
|
||||
|
||||
function drawFrame(t) {
|
||||
ctx.fillStyle = "#0a0a0a";
|
||||
ctx.fillRect(0, 0, 1920, 1080);
|
||||
for (var i = 0; i < 200; i++) {
|
||||
var x = hash(i, 0) * 1920;
|
||||
var y = hash(i, 1) * 1080;
|
||||
var brightness = hash(i, Math.floor(t * 10)) * 255;
|
||||
ctx.fillStyle = "rgba(255, 255, 255, " + brightness / 255 + ")";
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
var proxy = { time: 0 };
|
||||
tl.to(
|
||||
proxy,
|
||||
{
|
||||
time: 5,
|
||||
duration: 5,
|
||||
ease: "none",
|
||||
onUpdate: function () {
|
||||
drawFrame(proxy.time);
|
||||
},
|
||||
},
|
||||
0,
|
||||
);
|
||||
</script>
|
||||
```
|
||||
|
||||
The `hash()` function is deterministic — same frame renders identically every time.
|
||||
|
||||
---
|
||||
|
||||
## 3. CSS 3D Transforms
|
||||
|
||||
Perspective rotations create depth. Use for product showcases, card flips, architectural reveals.
|
||||
|
||||
```html
|
||||
<div class="stage" style="perspective: 900px;">
|
||||
<div class="card-3d" style="transform-style: preserve-3d;">
|
||||
<div class="face front">Product</div>
|
||||
<div class="face back" style="transform: rotateY(180deg);">Details</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
tl.to(".card-3d", { rotationY: 360, rotationX: 15, duration: 1.2, ease: "sine.inOut" }, 0);
|
||||
</script>
|
||||
```
|
||||
|
||||
Always set `perspective` on the parent, `transform-style: preserve-3d` on the animated element.
|
||||
|
||||
---
|
||||
|
||||
## 4. Per-Word Kinetic Typography
|
||||
|
||||
Words appear one-by-one, synced to transcript.json timestamps. The core technique for narration-driven videos.
|
||||
|
||||
```html
|
||||
<div class="headline">
|
||||
<span class="word w-0">Anything</span>
|
||||
<span class="word w-1">a</span>
|
||||
<span class="word w-2">browser</span>
|
||||
<span class="word w-3">can</span>
|
||||
<span class="word w-4">render</span>
|
||||
</div>
|
||||
<style>
|
||||
.word {
|
||||
display: inline-block;
|
||||
opacity: 0;
|
||||
margin: 0 0.12em;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
// Word onset times from transcript.json (seconds relative to beat start)
|
||||
var timings = [0.0, 0.23, 0.28, 0.63, 0.78];
|
||||
var slides = [80, 60, 50, 25, 12]; // horizontal slide decay (px)
|
||||
|
||||
document.querySelectorAll(".word").forEach(function (word, i) {
|
||||
tl.from(
|
||||
word,
|
||||
{
|
||||
x: slides[i],
|
||||
y: 14,
|
||||
opacity: 0,
|
||||
duration: 0.35,
|
||||
ease: "power2.out",
|
||||
},
|
||||
timings[i],
|
||||
);
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
The slide distance DECAYS per word (80→12px) — mimics a camera settling.
|
||||
|
||||
---
|
||||
|
||||
## 5. Lottie Animation
|
||||
|
||||
Vector animations that play inside a composition. Use for logos, character animations, icons.
|
||||
|
||||
```html
|
||||
<div id="logo-anim" class="lottie" style="width:500px;height:500px;"></div>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/bodymovin/5.12.2/lottie.min.js"></script>
|
||||
<script>
|
||||
window.__hfLottie = window.__hfLottie || [];
|
||||
|
||||
const anim = lottie.loadAnimation({
|
||||
container: document.getElementById("logo-anim"),
|
||||
renderer: "svg",
|
||||
loop: false,
|
||||
autoplay: false,
|
||||
path: "../capture/assets/lottie/animation-0.json",
|
||||
});
|
||||
window.__hfLottie.push(anim); // REQUIRED — adapter seeks every registered instance
|
||||
|
||||
gsap.set("#logo-anim", { scale: 0.3, opacity: 0 });
|
||||
tl.to("#logo-anim", { scale: 1, opacity: 1, duration: 0.35, ease: "back.out(1.6)" }, 0.2);
|
||||
</script>
|
||||
```
|
||||
|
||||
`autoplay: false` + `loop: false` + `window.__hfLottie.push()` are mandatory — HyperFrames seeks each registered player to composition time, so anything left on `autoplay`/`loop` runs in wall-clock and renders non-deterministically. The adapter seeks absolute time (no modulo loop, no playback-rate scaling): bake repeating cycles or non-default speed into the Lottie asset or an explicit timeline, then verify the render. Full contract + `.lottie`/dotLottie variant: `adapters/lottie.md`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Video Compositing
|
||||
|
||||
Embed real video footage inside compositions. Videos must be `muted` with `playsinline`.
|
||||
|
||||
```html
|
||||
<div class="video-frame" style="width:680px;height:840px;border-radius:16px;overflow:hidden;">
|
||||
<video
|
||||
id="footage"
|
||||
src="../capture/assets/videos/clip.mp4"
|
||||
muted
|
||||
playsinline
|
||||
style="width:100%;height:100%;object-fit:cover;"
|
||||
></video>
|
||||
</div>
|
||||
<script>
|
||||
// Video playback is controlled by the framework — don't call play() manually
|
||||
tl.from(".video-frame", { scale: 0.9, opacity: 0, duration: 0.3, ease: "power2.out" }, 0);
|
||||
</script>
|
||||
```
|
||||
|
||||
The HyperFrames runtime handles video seeking and playback.
|
||||
|
||||
---
|
||||
|
||||
## 7. Character-by-Character Typing
|
||||
|
||||
Terminal typing effect using `tl.call()` to update text content character by character.
|
||||
|
||||
```html
|
||||
<div class="terminal-line">
|
||||
<span class="prompt">❯</span>
|
||||
<span class="typed" id="typed-text"></span>
|
||||
<span class="cursor" style="width:11px;height:22px;background:#333;display:inline-block;"></span>
|
||||
</div>
|
||||
<script>
|
||||
var CMD = "npx hyperframes init";
|
||||
var typed = document.getElementById("typed-text");
|
||||
|
||||
// Cursor blinks
|
||||
tl.to(".cursor", { opacity: 0, duration: 0.12, yoyo: true, repeat: 20, ease: "steps(1)" }, 0);
|
||||
|
||||
// Type each character
|
||||
for (var i = 0; i < CMD.length; i++) {
|
||||
(function (idx) {
|
||||
tl.call(
|
||||
function () {
|
||||
typed.textContent = CMD.substring(0, idx + 1);
|
||||
},
|
||||
null,
|
||||
(idx / CMD.length) * 0.9,
|
||||
);
|
||||
})(i);
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
Use `ease: "steps(1)"` for cursor blink — creates discrete on/off.
|
||||
|
||||
---
|
||||
|
||||
## 8. Variable Font Axis Animation
|
||||
|
||||
Animate font-variation-settings to reshape glyphs in real-time. Works with variable fonts that have axes like optical size (opsz), weight (wght), softness (SOFT).
|
||||
|
||||
```html
|
||||
<style>
|
||||
/* Load the captured local variable font — do NOT use Google Fonts @import.
|
||||
Replace this placeholder with an @font-face pointing to ../capture/assets/fonts/. */
|
||||
@font-face {
|
||||
font-family: "Fraunces";
|
||||
src: url("../capture/assets/fonts/Fraunces-Variable.woff2") format("woff2");
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
.wordmark {
|
||||
--opsz: 144;
|
||||
--wght: 440;
|
||||
font-family: "Fraunces", serif;
|
||||
font-variation-settings:
|
||||
"opsz" var(--opsz),
|
||||
"wght" var(--wght);
|
||||
font-size: 200px;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
tl.to(".wordmark", { "--opsz": 72, "--wght": 300, duration: 0.45, ease: "power2.out" }, 0);
|
||||
</script>
|
||||
```
|
||||
|
||||
The glyph subtly reshapes as axes animate — optical size adjusts detail, weight changes thickness.
|
||||
|
||||
---
|
||||
|
||||
## 9. GSAP MotionPathPlugin
|
||||
|
||||
Animate an element along an arbitrary SVG path. Use for sliders following curves, particles along trajectories, guided reveals.
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/MotionPathPlugin.min.js"></script>
|
||||
<div class="dot" style="width:20px;height:20px;background:#2a8a7c;border-radius:50%;"></div>
|
||||
<script>
|
||||
gsap.registerPlugin(MotionPathPlugin);
|
||||
tl.to(
|
||||
".dot",
|
||||
{
|
||||
motionPath: { path: "M 12 300 C 280 280 520 80 820 50 S 1200 48 1308 38" },
|
||||
duration: 1.5,
|
||||
ease: "power2.out",
|
||||
},
|
||||
0,
|
||||
);
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Velocity-Matched Transitions
|
||||
|
||||
Exit one beat and enter the next with matched velocities — creates perceived continuous motion.
|
||||
|
||||
```javascript
|
||||
// EXIT (in outgoing composition): accelerating with blur
|
||||
tl.to(
|
||||
".content",
|
||||
{
|
||||
y: -150,
|
||||
filter: "blur(30px)",
|
||||
opacity: 0,
|
||||
duration: 0.33,
|
||||
ease: "power2.in", // accelerates
|
||||
},
|
||||
beatDuration - 0.33,
|
||||
);
|
||||
|
||||
// ENTRY (in incoming composition): decelerating from blur
|
||||
gsap.set(".content", { y: 150, filter: "blur(30px)" });
|
||||
tl.to(
|
||||
".content",
|
||||
{
|
||||
y: 0,
|
||||
filter: "blur(0px)",
|
||||
duration: 1.0,
|
||||
ease: "power2.out", // decelerates
|
||||
},
|
||||
0,
|
||||
);
|
||||
```
|
||||
|
||||
The fastest point of both curves meets at the cut — the viewer perceives smooth camera motion. Match ease families: `.in` for exits, `.out` for entries.
|
||||
|
||||
---
|
||||
|
||||
## 11. Audio-Reactive Animation
|
||||
|
||||
Drive any GSAP-tweenable property from the playing audio. Bass pulses a logo on kick drums. Treble glows a CTA on cymbals. Amplitude breathes a background during quiet phrases. The result: motion that feels locked to the track in a way pre-authored tweens never can.
|
||||
|
||||
**When to use:** Any video with music or dramatic narration — brand reels, product launches, hype edits. Skip for calm/tutorial pacing.
|
||||
|
||||
**How it works:** Pre-extract audio frequency bands into a JSON file, then sample per-frame via `tl.call()`:
|
||||
|
||||
```js
|
||||
// audio-data.json: { fps: 30, totalFrames: 900, frames: [{ bands: [0.82, 0.45, 0.31, ...] }, ...] }
|
||||
for (var f = 0; f < AUDIO_DATA.totalFrames; f++) {
|
||||
tl.call(
|
||||
(function (frame) {
|
||||
return function () {
|
||||
var bass = frame.bands[0]; // 0–1
|
||||
var treble = frame.bands[13];
|
||||
gsap.set(".logo", { scale: 1 + bass * 0.04 }); // 3–4% pulse on bass
|
||||
gsap.set(".cta", { filter: `drop-shadow(0 0 ${treble * 24}px #00C3FF)` });
|
||||
};
|
||||
})(AUDIO_DATA.frames[f]),
|
||||
[],
|
||||
f / AUDIO_DATA.fps,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Per-frame sampling is required — a single tween will not react. Use the extract script:
|
||||
|
||||
```bash
|
||||
python3 skills/hyperframes-creative/scripts/extract-audio-data.py narration.wav --fps 30 --bands 16 -o audio-data.json
|
||||
```
|
||||
|
||||
Keep text/logo intensity subtle (≤5% scale, ≤30% glow) — audio-reactive motion on tiny elements reads as jitter. Bigger backgrounds can push to 10–30%.
|
||||
|
||||
**Never do:** equalizer bars, spectrum analyzers, waveform displays, strobing, rainbow color cycling. The audio provides _timing and intensity_; the visual vocabulary still comes from the brand. See `skills/hyperframes-creative/references/audio-reactive.md` for the full API and anti-patterns.
|
||||
|
||||
---
|
||||
|
||||
## 12. Clip-Path Reveal Masks
|
||||
|
||||
A fixed window that content slides through — text or images enter from one side and are clipped by an invisible boundary. Different from SVG path drawing: the mask is static, the content moves.
|
||||
|
||||
```html
|
||||
<div id="reveal-mask">
|
||||
<div id="reveal-content">Your headline text here</div>
|
||||
</div>
|
||||
<style>
|
||||
#reveal-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
clip-path: inset(0 200px 0 0); /* clips 200px from right */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
#reveal-content {
|
||||
font-size: 108px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
// Content starts offscreen right, slides left through the mask window
|
||||
gsap.set("#reveal-content", { x: 400, opacity: 0 });
|
||||
tl.to("#reveal-content", { x: 0, opacity: 1, duration: 1, ease: "power2.out" }, 0);
|
||||
</script>
|
||||
```
|
||||
|
||||
Variations: `clip-path: circle(0% at 50% 50%)` → `circle(100%)` for iris reveals. `clip-path: polygon(...)` for custom shapes.
|
||||
|
||||
---
|
||||
|
||||
## 13. WebGL Fragment Shader Art
|
||||
|
||||
Full GPU generative backgrounds — domain-warped FBM noise, cosine palette coloring, iridescent organic patterns. Far richer than Canvas 2D.
|
||||
|
||||
```html
|
||||
<canvas id="shader-bg" width="1920" height="1080"></canvas>
|
||||
<script>
|
||||
var canvas = document.getElementById("shader-bg");
|
||||
var gl = canvas.getContext("webgl");
|
||||
if (!gl) {
|
||||
/* fallback to gradient */
|
||||
}
|
||||
|
||||
var fsrc = `
|
||||
precision mediump float;
|
||||
varying vec2 v_uv;
|
||||
uniform float u_time;
|
||||
uniform vec2 u_res;
|
||||
|
||||
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
|
||||
float noise(vec2 p) {
|
||||
vec2 i = floor(p), f = fract(p);
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
return mix(mix(hash(i), hash(i+vec2(1,0)), f.x),
|
||||
mix(hash(i+vec2(0,1)), hash(i+vec2(1,1)), f.x), f.y);
|
||||
}
|
||||
float fbm(vec2 p) {
|
||||
float v = 0.0, a = 0.5;
|
||||
mat2 R = mat2(0.8, 0.6, -0.6, 0.8);
|
||||
for (int i = 0; i < 5; i++) { v += a*noise(p); p = R*p*2.02; a *= 0.5; }
|
||||
return v;
|
||||
}
|
||||
vec3 palette(float t) {
|
||||
return vec3(0.5)+vec3(0.5)*cos(6.28318*(vec3(1)*t+vec3(0.0,0.33,0.67)));
|
||||
}
|
||||
void main() {
|
||||
vec2 uv = v_uv; uv.x *= u_res.x/u_res.y;
|
||||
float t = u_time * 0.4;
|
||||
vec2 q = vec2(fbm(uv*3.0+t*0.3), fbm(uv*3.0+vec2(5.2,1.3)+t*0.2));
|
||||
vec2 r = vec2(fbm(uv*3.0+q*4.0+vec2(1.7,9.2)+t*0.15), fbm(uv*3.0+q*4.0+vec2(8.3,2.8)+t*0.1));
|
||||
float n = fbm(uv*3.0+r*2.0);
|
||||
vec3 col = palette(n*2.0+t*0.2);
|
||||
col = mix(col, palette(length(q)*3.0+t*0.1), 0.4);
|
||||
col *= 0.7+0.3*n;
|
||||
float vig = 1.0-0.4*length(v_uv-0.5);
|
||||
gl_FragColor = vec4(col*vig, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
// Compile, link, set up fullscreen quad, then render via GSAP proxy:
|
||||
var proxy = { time: 0.5 };
|
||||
tl.to(
|
||||
proxy,
|
||||
{
|
||||
time: 5,
|
||||
duration: BEAT_DUR,
|
||||
ease: "none",
|
||||
onUpdate: function () {
|
||||
gl.uniform1f(uTime, proxy.time);
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||
},
|
||||
},
|
||||
0,
|
||||
);
|
||||
</script>
|
||||
```
|
||||
|
||||
Always include a Canvas 2D gradient fallback for environments without WebGL.
|
||||
|
||||
---
|
||||
|
||||
## When to Use What
|
||||
|
||||
| Video energy | Techniques to combine |
|
||||
| ------------------------------ | --------------------------------------------------------------- |
|
||||
| High impact (launches, promos) | Per-word typography + velocity transitions + counter animations |
|
||||
| Cinematic (tours, stories) | SVG path drawing + video compositing + 3D transforms |
|
||||
| Technical (dev tools, APIs) | Character typing + Canvas 2D procedural + MotionPath |
|
||||
| Premium (luxury, enterprise) | Variable font animation + Lottie + slow velocity transitions |
|
||||
| Data-driven (stats, metrics) | Canvas 2D procedural + counter animations + SVG path drawing |
|
||||
@@ -0,0 +1,167 @@
|
||||
# Transition Registry — machine source of truth
|
||||
|
||||
Single source of truth for **PLV scene-to-scene transitions**. The deterministic
|
||||
injector (`product-launch-video/scripts/inject-transitions.mjs`) reads the JSON
|
||||
block below and stamps the matching `gsap_template` onto the master timeline.
|
||||
The planner (`product-launch-video/agents/visual-design.md`) names a transition
|
||||
by its `name`; everything else is harness.
|
||||
|
||||
This file is **not** the catalog of all transitions — that is `catalog.md` +
|
||||
`css-*.md` (≈40 CSS + shader). This registry is the curated subset that is
|
||||
**Tier-B-ready**: pure transform / opacity / filter on the two scene **clip
|
||||
wrappers** (`#el-<sid>`), no injected overlay DOM, no per-scene cooperation.
|
||||
Overlay families (staggered blocks, blinds, light leak, grid dissolve, page
|
||||
burn) and shader transitions are deferred to later phases.
|
||||
|
||||
## How the injector applies a transition
|
||||
|
||||
At a `break` boundary between scene _i_ (`from`) and scene _i+1_ (`to`), the
|
||||
injector:
|
||||
|
||||
1. Extends `#el-<from>` wrapper `data-duration` by `duration_s` (holds its final
|
||||
frame — verified: `core/src/runtime/init.ts:1393-1410` external-slot branch).
|
||||
2. Pulls `#el-<to>` wrapper `data-start` earlier by `duration_s` (creates the
|
||||
overlap window).
|
||||
3. Reassigns **all** clip `data-track-index` as a 0/1 ping-pong so the two
|
||||
overlapping wrappers never share a track (same-track overlap is illegal —
|
||||
`core/src/lint/rules/composition.ts`). Higher track composites on top.
|
||||
4. Stamps the `gsap_template` into `window.__timelines["main"]` at `T = overlap-start`.
|
||||
|
||||
Verified by prototype render (2026-05-31): the master-timeline wrapper tween is
|
||||
seeked and rendered (no double-seek with the sub-comp's own paused timeline —
|
||||
the runtime drives them independently), the extended wrapper holds scene _i_'s
|
||||
final frame, and the higher-track incoming wrapper composites over + blends with
|
||||
the outgoing one.
|
||||
|
||||
## Template placeholders
|
||||
|
||||
The injector substitutes these tokens in each `gsap_template` line:
|
||||
|
||||
| Token | Meaning |
|
||||
| ---------------------------------- | ------------------------------------------------------------------------ |
|
||||
| `__OLD__` | `"#el-<from>"` — outgoing clip wrapper selector (quoted) |
|
||||
| `__NEW__` | `"#el-<to>"` — incoming clip wrapper selector (quoted) |
|
||||
| `__T__` | overlap-start time in seconds (master clock) |
|
||||
| `__DUR__` | `duration_s` for this boundary |
|
||||
| `__DX__` | horizontal travel for directional types: `-1920` (LEFT) / `1920` (RIGHT) |
|
||||
| `__DY__` | vertical travel: `-1080` (UP) / `1080` (DOWN) |
|
||||
| `__ORIGIN_OUT__` / `__ORIGIN_IN__` | transformOrigin pair for `squeeze` |
|
||||
|
||||
`filter` / `scaleX` / `transformOrigin` are lint-clean on the master timeline
|
||||
(verified: `core/src/lint/rules/gsap.ts` has no per-property whitelist and scopes
|
||||
its checks to `data-composition-id` ranges; the x/y/scale/rotation/opacity
|
||||
whitelist is a _scene-worker_ prompt rule only — it does not bind index.html).
|
||||
|
||||
## Registry
|
||||
|
||||
```json
|
||||
{
|
||||
"transitions": [
|
||||
{
|
||||
"name": "crossfade",
|
||||
"tier": "b",
|
||||
"overlay": false,
|
||||
"energy": "any",
|
||||
"default_duration_s": 0.5,
|
||||
"directions": [],
|
||||
"source": "css-dissolve.md",
|
||||
"gsap_template": [
|
||||
"tl.to(__OLD__, { opacity: 0, duration: __DUR__, ease: \"power2.inOut\" }, __T__);",
|
||||
"tl.fromTo(__NEW__, { opacity: 0 }, { opacity: 1, duration: __DUR__, ease: \"power2.inOut\" }, __T__);"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "blur-crossfade",
|
||||
"tier": "b",
|
||||
"overlay": false,
|
||||
"energy": "calm",
|
||||
"default_duration_s": 0.6,
|
||||
"directions": [],
|
||||
"source": "css-dissolve.md",
|
||||
"note": "Default when the two scenes' #root backgrounds differ a lot — the blur masks the background-color clash a plain crossfade would expose.",
|
||||
"gsap_template": [
|
||||
"tl.to(__OLD__, { filter: \"blur(10px)\", scale: 1.03, opacity: 0, duration: __DUR__, ease: \"power2.inOut\" }, __T__);",
|
||||
"tl.fromTo(__NEW__, { filter: \"blur(10px)\", scale: 0.97, opacity: 0 }, { filter: \"blur(0px)\", scale: 1, opacity: 1, duration: __DUR__, ease: \"power2.inOut\" }, __T__);"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "push-slide",
|
||||
"tier": "b",
|
||||
"overlay": false,
|
||||
"energy": "medium",
|
||||
"default_duration_s": 0.5,
|
||||
"directions": ["LEFT", "RIGHT", "UP", "DOWN"],
|
||||
"default_direction": "LEFT",
|
||||
"source": "css-push.md",
|
||||
"note": "Directional. The injector picks __DX__/__DY__ from the direction and emits the horizontal OR vertical pair (not both).",
|
||||
"gsap_template_horizontal": [
|
||||
"tl.to(__OLD__, { x: __DX__, duration: __DUR__, ease: \"power3.inOut\" }, __T__);",
|
||||
"tl.fromTo(__NEW__, { x: __DXIN__, opacity: 1 }, { x: 0, duration: __DUR__, ease: \"power3.inOut\" }, __T__);"
|
||||
],
|
||||
"gsap_template_vertical": [
|
||||
"tl.to(__OLD__, { y: __DY__, duration: __DUR__, ease: \"power3.inOut\" }, __T__);",
|
||||
"tl.fromTo(__NEW__, { y: __DYIN__, opacity: 1 }, { y: 0, duration: __DUR__, ease: \"power3.inOut\" }, __T__);"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "zoom-through",
|
||||
"tier": "b",
|
||||
"overlay": false,
|
||||
"energy": "high",
|
||||
"default_duration_s": 0.4,
|
||||
"directions": [],
|
||||
"source": "css-scale.md",
|
||||
"gsap_template": [
|
||||
"tl.to(__OLD__, { scale: 2.5, opacity: 0, filter: \"blur(8px)\", duration: __DUR__, ease: \"power3.in\" }, __T__);",
|
||||
"tl.fromTo(__NEW__, { scale: 0.5, opacity: 0, filter: \"blur(8px)\" }, { scale: 1, opacity: 1, filter: \"blur(0px)\", duration: __DUR__, ease: \"power3.out\" }, __T__);"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "squeeze",
|
||||
"tier": "b",
|
||||
"overlay": false,
|
||||
"energy": "medium",
|
||||
"default_duration_s": 0.4,
|
||||
"directions": [],
|
||||
"source": "css-push.md",
|
||||
"note": "Old compresses to a vertical line on the left edge; new expands from the right edge. Incoming starts off (scaleX 0) so its higher-track stacking is harmless.",
|
||||
"gsap_template": [
|
||||
"tl.to(__OLD__, { scaleX: 0, transformOrigin: \"left center\", duration: __DUR__, ease: \"power3.inOut\" }, __T__);",
|
||||
"tl.fromTo(__NEW__, { scaleX: 0, transformOrigin: \"right center\", opacity: 1 }, { scaleX: 1, transformOrigin: \"right center\", duration: __DUR__, ease: \"power3.inOut\" }, __T__);"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tier_a_types": ["morph", "shared-element"],
|
||||
"default_high_energy": "zoom-through",
|
||||
"default_calm": "blur-crossfade",
|
||||
"max_duration_s": 2.0
|
||||
}
|
||||
```
|
||||
|
||||
## Default-derivation (used by prep.mjs when the planner omits `**Transition:**`)
|
||||
|
||||
A `break` boundary with no named transition gets a default:
|
||||
|
||||
1. If the incoming scene's creative brief reads HIGH energy (explosive / kinetic /
|
||||
frenetic keywords), use `default_high_energy` (`zoom-through`).
|
||||
2. Otherwise use `default_calm` (`blur-crossfade`) — the universal default. The
|
||||
blur masks any background shift and reads intentional, which keeps the whole
|
||||
video to ~2 transition types (the "repeat 2-3" principle).
|
||||
|
||||
## Choosing as a planner (the only agent touchpoint)
|
||||
|
||||
Pick **2-3 types for the whole video** and repeat them — repetition is what reads
|
||||
as professional (see `overview.md`). This budget counts the **Tier-B between-scene
|
||||
types only** (the 5 in the registry above); the Tier-A `shared-element` morph is a
|
||||
worker-authored bridge driven by narrative `intent: morph` — it is **exempt and
|
||||
does not count** toward the 2-3. Name the entering transition on each scene:
|
||||
|
||||
```
|
||||
**Transition:** blur-crossfade
|
||||
**Transition:** push-slide LEFT
|
||||
**Transition:** zoom-through 0.3s
|
||||
```
|
||||
|
||||
Omit the anchor to accept the default above. Do NOT write GSAP, touch timing, or
|
||||
edit index.html — the harness stamps the code, computes the overlap, and assigns
|
||||
tracks.
|
||||
@@ -0,0 +1,127 @@
|
||||
# Transition Catalog
|
||||
|
||||
Hard rules, scene template, and routing to implementation code. Read the reference file for the transition type you need — don't load all of them.
|
||||
|
||||
## Contents
|
||||
|
||||
- Hard rules for CSS transitions
|
||||
- Shader transitions
|
||||
- Scene template
|
||||
- CSS transition examples
|
||||
- Shader transition routing
|
||||
|
||||
## Hard Rules (CSS)
|
||||
|
||||
These cause real bugs if violated.
|
||||
|
||||
**Scene visibility:** Scene 1 visible by default (no `opacity: 0`). Scenes 2+ have `opacity: 0` on the CONTAINER div. GSAP reveals them. No visibility shim (`timedEls`).
|
||||
|
||||
**Fonts:** Just write the `font-family` you want — the compiler embeds supported fonts automatically via `@font-face` with inline data URIs. No need for `<link>` tags or `@import`. Works in all contexts including sandboxed iframes.
|
||||
|
||||
**Element structure:** No `class="clip"` on scene divs in standalone compositions. Only the root div gets `data-composition-id`/`data-start`/`data-duration`.
|
||||
|
||||
**Overlay elements:** Staggered blocks = full-screen 1920x1080, NOT thin strips. Glitch RGB overlays = normal blending at 35% opacity, NOT `mix-blend-mode: multiply` (invisible on dark backgrounds). Light leak overlays = larger than the frame (2400px+), never a visible shape. Overexposure = use `filter: brightness()` on the scene, not just a white overlay.
|
||||
|
||||
**VHS tape:** Clone actual scene content with `cloneNode(true)`, NOT colored bars. Each strip: wider than frame (2020px at left:-50px). Red+blue chromatic copies at z-index above main strip. Seeded PRNG for deterministic random offsets.
|
||||
|
||||
**Z-index:** Gravity drop, zoom out, diagonal split need outgoing scene ON TOP (`zIndex: 10`) so it exits while revealing the new scene behind (`zIndex: 1`).
|
||||
|
||||
**Page burn:** Content burns with the page — no falling debris. Hide scene1 via `tl.set` at burn end, NEVER `onComplete` (not reversible). `onUpdate` must restore `clipPath: "none"` when `wp <= 0` for rewind support. Incoming scene fades from black at 90% through burn.
|
||||
|
||||
**Clock wipe:** 9-point polygon with intermediate edge positions. Step through 4 quadrants with separate tweens.
|
||||
|
||||
**Grid dissolve:** Cycle 5 palette colors per cell, not monochrome.
|
||||
|
||||
**Blinds count by energy:** Calm: 4h/6v. Medium: 6-8h/8v. High: 12-16h/16v.
|
||||
|
||||
**Don't use:** Star iris (polygon interpolation broken), tilt-shift (no selective CSS blur), lens flare (visible shape, not optical), hinge/door (distorts too fast).
|
||||
|
||||
## Shader Transitions
|
||||
|
||||
Shader setup, WebGL init, capture, and fragment shaders are handled by `@hyperframes/shader-transitions` (`packages/shader-transitions/`). Read the package source for API details. Compositions using shaders must follow the shader-compatible CSS rules in `overview.md` (this directory).
|
||||
|
||||
## Scene Template
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
font-family: "YOUR FONT", sans-serif; /* compiler embeds supported fonts automatically */
|
||||
}
|
||||
.scene {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
}
|
||||
#scene1 {
|
||||
z-index: 1;
|
||||
background: #color;
|
||||
}
|
||||
#scene2 {
|
||||
z-index: 2;
|
||||
background: #color;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="root"
|
||||
data-composition-id="main"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
data-start="0"
|
||||
data-duration="TOTAL"
|
||||
>
|
||||
<div id="scene1" class="scene"><!-- visible --></div>
|
||||
<div id="scene2" class="scene"><!-- hidden --></div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
// Transition code here
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
Every transition follows: position new scene → animate outgoing → swap → animate incoming → clean up overlays.
|
||||
|
||||
## CSS Transitions
|
||||
|
||||
All code examples use `old` for the outgoing scene-inner selector and `new` for the incoming, with `T` as the transition start time. Read the reference file for the type you need.
|
||||
|
||||
| Type | Transitions | Reference |
|
||||
| -------------- | ---------------------------------------------------- | -------------------------------- |
|
||||
| Push | Push slide, vertical push, elastic push, squeeze | `transitions/css-push.md` |
|
||||
| Radial / Shape | Circle iris, diamond iris, diagonal split | `transitions/css-radial.md` |
|
||||
| 3D | 3D card flip | `transitions/css-3d.md` |
|
||||
| Scale / Zoom | Zoom through, zoom out | `transitions/css-scale.md` |
|
||||
| Dissolve | Crossfade, blur crossfade, focus pull, color dip | `transitions/css-dissolve.md` |
|
||||
| Cover | Staggered blocks, horizontal blinds, vertical blinds | `transitions/css-cover.md` |
|
||||
| Light | Light leak, overexposure burn, film burn | `transitions/css-light.md` |
|
||||
| Distortion | Glitch, chromatic aberration, ripple, VHS tape | `transitions/css-distortion.md` |
|
||||
| Mechanical | Shutter, clock wipe | `transitions/css-mechanical.md` |
|
||||
| Grid | Grid dissolve | `transitions/css-grid.md` |
|
||||
| Other | Gravity drop, morph circle | `transitions/css-other.md` |
|
||||
| Blur | Blur through, directional blur | `transitions/css-blur.md` |
|
||||
| Destruction | Page burn | `transitions/css-destruction.md` |
|
||||
|
||||
## Shader Transitions
|
||||
|
||||
WebGL shader transitions are provided by `@hyperframes/shader-transitions` (`packages/shader-transitions/`). The package handles setup, capture, WebGL init, render loop, and GSAP integration. Read the package source for available shaders and API — do not copy raw GLSL manually.
|
||||
|
||||
The built-ins are not a ceiling. For an effect no built-in covers, you can write custom GLSL from scratch, adapt shader code found online (ShaderToy, GLSL Sandbox, GitHub), or build a custom CSS transition that fits no existing category — combine clip-path, transforms, and filters in new ways. If the storyboard calls for an effect that doesn't exist yet, build it; the framework renders anything a browser can run.
|
||||
@@ -0,0 +1,12 @@
|
||||
## 3D
|
||||
|
||||
### 3D Card Flip
|
||||
|
||||
180° Y-axis rotation. Requires CSS: `backface-visibility: hidden; transform-style: preserve-3d;` on both scene-inners. Parent needs `perspective: 1200px`.
|
||||
|
||||
```js
|
||||
tl.set(new, { rotationY: -180, opacity: 1 }, T);
|
||||
tl.to(old, { rotationY: 180, duration: 0.6, ease: "power2.inOut" }, T);
|
||||
tl.to(new, { rotationY: 0, duration: 0.6, ease: "power2.inOut" }, T);
|
||||
tl.set(old, { opacity: 0 }, T + 0.6);
|
||||
```
|
||||
@@ -0,0 +1,51 @@
|
||||
## Blur
|
||||
|
||||
All blur transitions scale with energy. See SKILL.md "Blur Intensity by Energy" for the full table.
|
||||
|
||||
### Blur Through
|
||||
|
||||
Content becomes fully abstract before resolving. The heaviest blur transition.
|
||||
|
||||
**Calm (default for this type — it's inherently heavy):**
|
||||
|
||||
```js
|
||||
tl.to(old, { filter: "blur(30px)", scale: 1.08, duration: 0.5, ease: "power1.in" }, T);
|
||||
tl.to(old, { opacity: 0, duration: 0.3, ease: "power1.in" }, T + 0.3);
|
||||
// Hold: both scenes in abstract blur state
|
||||
tl.fromTo(new,
|
||||
{ filter: "blur(30px)", scale: 0.92, opacity: 0 },
|
||||
{ filter: "blur(30px)", scale: 0.92, opacity: 1, duration: 0.2, ease: "none" }, T + 0.5);
|
||||
// Slow resolve
|
||||
tl.to(new, { filter: "blur(0px)", scale: 1, duration: 0.7, ease: "power1.out" }, T + 0.7);
|
||||
```
|
||||
|
||||
**Medium:**
|
||||
|
||||
```js
|
||||
tl.to(old, { filter: "blur(15px)", scale: 1.05, opacity: 0, duration: 0.4, ease: "power2.in" }, T);
|
||||
tl.fromTo(new,
|
||||
{ filter: "blur(15px)", scale: 0.95, opacity: 0 },
|
||||
{ filter: "blur(0px)", scale: 1, opacity: 1, duration: 0.4, ease: "power2.out" }, T + 0.2);
|
||||
```
|
||||
|
||||
### Directional Blur
|
||||
|
||||
Blur + skew simulating motion in one direction. Scale blur and skew with energy.
|
||||
|
||||
**Medium (default):**
|
||||
|
||||
```js
|
||||
tl.to(old, { filter: "blur(12px)", skewX: -8, x: -200, opacity: 0, duration: 0.4, ease: "power3.in" }, T);
|
||||
tl.fromTo(new,
|
||||
{ filter: "blur(12px)", skewX: 8, x: 200, opacity: 0 },
|
||||
{ filter: "blur(0px)", skewX: 0, x: 0, opacity: 1, duration: 0.4, ease: "power3.out" }, T + 0.15);
|
||||
```
|
||||
|
||||
**Calm (heavier blur, gentler motion):**
|
||||
|
||||
```js
|
||||
tl.to(old, { filter: "blur(20px)", skewX: -4, x: -100, opacity: 0, duration: 0.6, ease: "power1.in" }, T);
|
||||
tl.fromTo(new,
|
||||
{ filter: "blur(20px)", skewX: 4, x: 100, opacity: 0 },
|
||||
{ filter: "blur(0px)", skewX: 0, x: 0, opacity: 1, duration: 0.6, ease: "power1.out" }, T + 0.3);
|
||||
```
|
||||
@@ -0,0 +1,43 @@
|
||||
## Cover
|
||||
|
||||
### Staggered Color Blocks
|
||||
|
||||
Full-screen (1920x1080) colored divs slide across staggered. Scene swaps while covered.
|
||||
|
||||
**2-block** (standard):
|
||||
|
||||
```js
|
||||
tl.set("#wipe-a", { x: -1920 }, T - 0.01);
|
||||
tl.set("#wipe-b", { x: -1920 }, T - 0.01);
|
||||
tl.to("#wipe-a", { x: 0, duration: 0.25, ease: "power3.inOut" }, T);
|
||||
tl.to("#wipe-b", { x: 0, duration: 0.25, ease: "power3.inOut" }, T + 0.06);
|
||||
tl.set(old, { opacity: 0 }, T + 0.2);
|
||||
tl.set(new, { opacity: 1 }, T + 0.2);
|
||||
tl.to("#wipe-a", { x: 1920, duration: 0.25, ease: "power3.inOut" }, T + 0.28);
|
||||
tl.to("#wipe-b", { x: 1920, duration: 0.25, ease: "power3.inOut" }, T + 0.34);
|
||||
```
|
||||
|
||||
**5-block** (dense variant): same pattern with 5 blocks at 0.04s stagger. Use composition palette colors.
|
||||
|
||||
### Horizontal Blinds
|
||||
|
||||
Full-width strips slide across staggered. Each strip: `width: 1920px; height: Xpx`.
|
||||
|
||||
**6 strips** (180px each): `0.03s` stagger
|
||||
**12 strips** (90px each): `0.018s` stagger
|
||||
|
||||
```js
|
||||
for (var i = 0; i < N; i++) {
|
||||
tl.set("#blind-h-" + i, { x: -1920 }, T - 0.01);
|
||||
tl.fromTo("#blind-h-" + i, { x: -1920 }, { x: 0, duration: 0.2, ease: "power3.inOut" }, T + i * stagger);
|
||||
}
|
||||
tl.set(old, { opacity: 0 }, T + coverTime);
|
||||
tl.set(new, { opacity: 1 }, T + coverTime);
|
||||
for (var i = 0; i < N; i++) {
|
||||
tl.to("#blind-h-" + i, { x: 1920, duration: 0.2, ease: "power3.inOut" }, T + exitStart + i * stagger);
|
||||
}
|
||||
```
|
||||
|
||||
### Vertical Blinds
|
||||
|
||||
Same as horizontal but strips are tall and narrow, moving on Y axis.
|
||||
@@ -0,0 +1,95 @@
|
||||
## Destruction
|
||||
|
||||
### Page Burn
|
||||
|
||||
The outgoing scene literally burns away from a corner. A fire front expands with noise-based irregular edges, a canvas draws the scorched char line at the burn boundary, and individual text characters/elements chip off and fall with gravity as the fire reaches them. The incoming scene reveals behind the burn.
|
||||
|
||||
This transition has three systems working together:
|
||||
|
||||
1. **Fire geometry** — a radial front expanding from a corner (e.g., bottom-right) with noise-based irregularity for organic edges
|
||||
2. **Scene clipping** — the outgoing scene uses an SVG clip-path (with `fill-rule: evenodd`) that cuts a hole matching the fire front. As the fire expands, more of the scene is clipped away. All content (text, images, lines) burns with the page — no separate debris.
|
||||
3. **Scorched edge** — a `<canvas>` overlay draws a radial gradient fringe at the fire boundary to simulate charring
|
||||
|
||||
**When to use:** Dramatic reveals, edgy/destructive mood, gaming, cyberpunk. This is the most dramatic transition in the catalog — reserve it for hero moments.
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- A `<canvas>` element for the burn edge overlay
|
||||
- A noise function for organic fire edge geometry
|
||||
- SVG clip-path with evenodd fill-rule for the inverted clip
|
||||
|
||||
**Fire geometry (deterministic noise):**
|
||||
|
||||
```js
|
||||
function noise(x) {
|
||||
var ix = Math.floor(x),
|
||||
fx = x - ix;
|
||||
var a = Math.sin(ix * 127.1 + 311.7) * 43758.5453;
|
||||
var b = Math.sin((ix + 1) * 127.1 + 311.7) * 43758.5453;
|
||||
var t = fx * fx * (3 - 2 * fx);
|
||||
return a - Math.floor(a) + (b - Math.floor(b) - (a - Math.floor(a))) * t;
|
||||
}
|
||||
|
||||
function fireRadiusAtAngle(angle, progress) {
|
||||
var base = progress * maxRadius;
|
||||
return (
|
||||
base +
|
||||
noise(angle * 3 + progress * 4) * 50 +
|
||||
noise(angle * 8 + progress * 9) * 20 +
|
||||
noise(angle * 15 + progress * 15) * 8
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Incoming scene timing:** The incoming scene should NOT be visible during the burn. As the fire consumes the outgoing scene, **black shows through the holes** — this is the dramatic part. The viewer watches content being destroyed against blackness.
|
||||
|
||||
At ~90% through the burn, the incoming scene fades in SLOWLY from black — the background first, then content staggered. Use long, gentle fades (`power1.out`, 0.8-1.2s durations) so it feels like the new scene materializes from darkness, not a hard swap.
|
||||
|
||||
```js
|
||||
// Scene 2 stays at opacity: 0 during the burn — black behind the fire
|
||||
tl.set("#s2-title", { opacity: 0 }, T);
|
||||
tl.set("#s2-subtitle", { opacity: 0 }, T);
|
||||
|
||||
// At 90% through, scene bg fades in slowly from black
|
||||
var contentReveal = T + BURN_DURATION * 0.9;
|
||||
tl.to("#scene2", { opacity: 1, duration: 1.2, ease: "power1.out" }, contentReveal);
|
||||
|
||||
// Content fades in staggered on top, even slower
|
||||
tl.to("#s2-title", { opacity: 1, duration: 1.0, ease: "power1.out" }, contentReveal + 0.5);
|
||||
tl.to("#s2-subtitle", { opacity: 1, duration: 0.8, ease: "power1.out" }, contentReveal + 0.7);
|
||||
```
|
||||
|
||||
**Content burns with the page — no falling debris.** The clip-path on scene1 IS the effect — as the fire shape expands, everything behind the fire edge (text, images, lines) disappears naturally. Don't clone elements, don't create falling debris. The content is part of the page being consumed. The scorched canvas edge provides the visual char line at the burn boundary.
|
||||
|
||||
**Hide scene1 via `tl.set` at burn end — NEVER in `onComplete`.** Using `onComplete` to hide scene1 is not reversible when scrubbing. Instead, use a `tl.set` at the exact burn end time:
|
||||
|
||||
```js
|
||||
tl.to(
|
||||
burnState,
|
||||
{
|
||||
progress: 1,
|
||||
duration: BURN_DURATION,
|
||||
ease: "none",
|
||||
onUpdate: function () {
|
||||
var wp = burnState.progress;
|
||||
var scene1 = document.getElementById("scene1");
|
||||
if (wp <= 0) {
|
||||
scene1.style.clipPath = "none"; // fully visible when rewound
|
||||
} else if (wp < 1) {
|
||||
scene1.style.clipPath = buildClipPath(wp);
|
||||
}
|
||||
drawEdge(wp);
|
||||
},
|
||||
// NO onComplete — use tl.set instead
|
||||
},
|
||||
T,
|
||||
);
|
||||
|
||||
// Hide scene1 at exact burn end — reversible via timeline
|
||||
tl.set("#scene1", { opacity: 0 }, T + BURN_DURATION);
|
||||
tl.set("#scene1", { clipPath: "none" }, T + BURN_DURATION);
|
||||
```
|
||||
|
||||
The `onUpdate` handles clip-path and canvas edge per-frame. The `tl.set` handles the final hide — and GSAP automatically reverses it when scrubbing backward, restoring scene1 to `opacity: 1`.
|
||||
|
||||
The `onUpdate` callback is the key — it runs every frame to advance the clip-path and canvas edge in sync with the timeline.
|
||||
@@ -0,0 +1,66 @@
|
||||
## Dissolve
|
||||
|
||||
### Crossfade
|
||||
|
||||
Simple opacity swap. The baseline.
|
||||
|
||||
```js
|
||||
tl.to(old, { opacity: 0, duration: 0.5, ease: "power2.inOut" }, T);
|
||||
tl.fromTo(new, { opacity: 0 }, { opacity: 1, duration: 0.5, ease: "power2.inOut" }, T);
|
||||
```
|
||||
|
||||
### Blur Crossfade
|
||||
|
||||
Dissolve with blur + scale shift. **Scale blur amount by energy** — see SKILL.md "Blur Intensity by Energy" section. The examples below show the medium (default) version. For calm compositions, increase to 20-30px with a 0.3-0.5s hold at peak blur. For high-energy, decrease to 3-6px with no hold.
|
||||
|
||||
**Medium (default):**
|
||||
|
||||
```js
|
||||
tl.to(old, { filter: "blur(10px)", scale: 1.03, opacity: 0, duration: 0.5, ease: "power2.inOut" }, T);
|
||||
tl.fromTo(new,
|
||||
{ filter: "blur(10px)", scale: 0.97, opacity: 0 },
|
||||
{ filter: "blur(0px)", scale: 1, opacity: 1, duration: 0.5, ease: "power2.inOut" }, T + 0.1);
|
||||
```
|
||||
|
||||
**Calm (wellness, luxury) — heavy blur, holds at abstract color:**
|
||||
|
||||
```js
|
||||
tl.to(old, { filter: "blur(25px)", scale: 1.05, duration: 0.6, ease: "power1.in" }, T);
|
||||
tl.to(old, { opacity: 0, duration: 0.4, ease: "power1.in" }, T + 0.4);
|
||||
tl.fromTo(new,
|
||||
{ filter: "blur(25px)", scale: 0.95, opacity: 0 },
|
||||
{ filter: "blur(25px)", scale: 0.95, opacity: 1, duration: 0.3, ease: "power1.inOut" }, T + 0.5);
|
||||
tl.to(new, { filter: "blur(0px)", scale: 1, duration: 0.6, ease: "power1.out" }, T + 0.8);
|
||||
```
|
||||
|
||||
### Focus Pull
|
||||
|
||||
Outgoing slowly blurs while incoming fades in sharp. Depth-of-field feel. **Scale blur amount and hold duration by energy.**
|
||||
|
||||
**Medium:**
|
||||
|
||||
```js
|
||||
tl.to(old, { filter: "blur(15px)", duration: 0.5, ease: "power1.in" }, T);
|
||||
tl.to(old, { opacity: 0, duration: 0.3, ease: "power2.in" }, T + 0.25);
|
||||
tl.fromTo(new, { opacity: 0 }, { opacity: 1, duration: 0.3, ease: "power2.out" }, T + 0.25);
|
||||
```
|
||||
|
||||
**Calm — slow rack focus with long hold at peak defocus:**
|
||||
|
||||
```js
|
||||
tl.to(old, { filter: "blur(30px)", duration: 0.8, ease: "power1.in" }, T);
|
||||
tl.to(old, { opacity: 0, duration: 0.5, ease: "power1.in" }, T + 0.6);
|
||||
tl.fromTo(new, { opacity: 0, filter: "blur(20px)" },
|
||||
{ opacity: 1, filter: "blur(20px)", duration: 0.3, ease: "power1.inOut" }, T + 0.7);
|
||||
tl.to(new, { filter: "blur(0px)", duration: 0.6, ease: "power1.out" }, T + 1.0);
|
||||
```
|
||||
|
||||
### Color Dip
|
||||
|
||||
Fade to solid color, hold, fade up new scene.
|
||||
|
||||
```js
|
||||
tl.to(old, { opacity: 0, duration: 0.2, ease: "power2.in" }, T);
|
||||
// Background color shows through
|
||||
tl.fromTo(new, { opacity: 0 }, { opacity: 1, duration: 0.2, ease: "power2.out" }, T + 0.25);
|
||||
```
|
||||
@@ -0,0 +1,45 @@
|
||||
## Distortion
|
||||
|
||||
### Glitch
|
||||
|
||||
RGB-tinted overlays (NOT multiply blend — use normal blending at 35% opacity) jitter with large offsets. Scene itself also jitters.
|
||||
|
||||
```js
|
||||
tl.set("#glitch-r", { opacity: 1, x: 40, y: -8 }, T);
|
||||
tl.set("#glitch-g", { opacity: 1, x: -30, y: 12 }, T);
|
||||
tl.set("#glitch-b", { opacity: 1, x: 15, y: -20 }, T);
|
||||
tl.set(old, { x: -15 }, T);
|
||||
// 6 jitter frames at 0.03s intervals with big offsets (±30-60px)
|
||||
// ... swap and clear at T + 0.2
|
||||
```
|
||||
|
||||
### Chromatic Aberration
|
||||
|
||||
RGB overlays start aligned then spread apart (±80px), scene fades, converge on new scene.
|
||||
|
||||
```js
|
||||
tl.set("#glitch-r", { opacity: 0.6, x: 0 }, T);
|
||||
tl.set("#glitch-g", { opacity: 0.6, x: 0 }, T);
|
||||
tl.set("#glitch-b", { opacity: 0.6, x: 0 }, T);
|
||||
tl.to("#glitch-r", { x: -80, opacity: 0.8, duration: 0.3, ease: "power2.in" }, T);
|
||||
tl.to("#glitch-b", { x: 80, opacity: 0.8, duration: 0.3, ease: "power2.in" }, T);
|
||||
tl.to("#glitch-g", { y: 30, duration: 0.3, ease: "power2.in" }, T);
|
||||
// Swap at T + 0.3, converge back at T + 0.3
|
||||
```
|
||||
|
||||
### Ripple
|
||||
|
||||
Rapid oscillation (±30px) + scale distortion (0.97-1.03) + increasing blur. Swap at peak distortion.
|
||||
|
||||
```js
|
||||
tl.to(old, { x: 30, scale: 1.02, duration: 0.04, ease: "none" }, T);
|
||||
tl.to(old, { x: -25, scale: 0.98, filter: "blur(4px)", duration: 0.04, ease: "none" }, T + 0.04);
|
||||
// ... more oscillations with increasing blur
|
||||
// Swap at peak, incoming stabilizes with decreasing wobble
|
||||
```
|
||||
|
||||
### VHS Tape
|
||||
|
||||
Clone scene into 20 horizontal strips (each 54px, clip-path'd). Each strip shifts x independently with seeded pseudo-random offsets at per-bar random intervals. Add red+blue chromatic offset copies on each strip (z-index above main, 35% opacity). Make strips wider than frame (2020px at left:-50px) so edges never show.
|
||||
|
||||
See SKILL.md for clone-based implementation pattern.
|
||||
@@ -0,0 +1,10 @@
|
||||
## Grid
|
||||
|
||||
### Grid Dissolve
|
||||
|
||||
Grid of colored cells covers the frame in a ripple from center. Scene swaps at 50% coverage. Cells fade out in ripple.
|
||||
|
||||
**12-cell** (4x3, each 480x270): standard
|
||||
**120-cell** (12x10, each 160x108): dense variant — lower opacity (0.75), tighter ripple
|
||||
|
||||
Cells are created dynamically in JS, sorted by distance from center for ripple stagger.
|
||||
@@ -0,0 +1,49 @@
|
||||
## Light
|
||||
|
||||
### Light Leak
|
||||
|
||||
Multiple warm-colored overlays wash across frame. Needs: a flat warm tint layer + 2-3 bright radial gradient divs, all larger than the frame so edges are never visible.
|
||||
|
||||
```js
|
||||
// Warm tint washes over entire frame
|
||||
tl.to("#leak-warm", { opacity: 0.4, duration: 0.3, ease: "power1.in" }, T);
|
||||
// Bright leak elements drift in
|
||||
tl.to("#leak-1", { opacity: 0.9, x: 300, duration: 0.5, ease: "sine.inOut" }, T + 0.05);
|
||||
tl.to("#leak-2", { opacity: 0.8, x: 200, duration: 0.6, ease: "sine.inOut" }, T + 0.1);
|
||||
// Peak warmth then swap
|
||||
tl.to("#leak-warm", { opacity: 0.6, duration: 0.15, ease: "power2.in" }, T + 0.35);
|
||||
tl.set(old, { opacity: 0 }, T + 0.45);
|
||||
tl.set(new, { opacity: 1 }, T + 0.45);
|
||||
// Leak fades
|
||||
tl.to("#leak-warm", { opacity: 0, duration: 0.4, ease: "power2.out" }, T + 0.5);
|
||||
tl.to("#leak-1", { opacity: 0, x: 600, duration: 0.35, ease: "power1.out" }, T + 0.5);
|
||||
```
|
||||
|
||||
### Overexposure Burn
|
||||
|
||||
Scene progressively blows out to white using CSS `filter: brightness()`, then white overlay fades in. Swap at peak white. White recedes to reveal new scene.
|
||||
|
||||
```js
|
||||
tl.to(old, { filter: "brightness(1.5)", scale: 1.03, duration: 0.2, ease: "power1.in" }, T);
|
||||
tl.to(old, { filter: "brightness(3)", scale: 1.06, duration: 0.2, ease: "power2.in" }, T + 0.2);
|
||||
tl.to("#flash-overlay", { opacity: 0.5, duration: 0.25, ease: "power1.in" }, T + 0.15);
|
||||
tl.to("#flash-overlay", { opacity: 1, duration: 0.15, ease: "power2.in" }, T + 0.4);
|
||||
tl.set(old, { opacity: 0, filter: "brightness(1)", scale: 1 }, T + 0.55);
|
||||
tl.set(new, { opacity: 1 }, T + 0.55);
|
||||
tl.to("#flash-overlay", { opacity: 0, duration: 0.35, ease: "power2.out" }, T + 0.55);
|
||||
```
|
||||
|
||||
### Film Burn
|
||||
|
||||
Staggered warm overlays (amber, orange, red) bleed from one edge. Each overlay is a large radial gradient div at high z-index.
|
||||
|
||||
```js
|
||||
tl.to("#burn-a", { opacity: 1, x: -300, duration: 0.4, ease: "power1.in" }, T);
|
||||
tl.to("#burn-b", { opacity: 1, x: -500, duration: 0.5, ease: "power1.in" }, T + 0.05);
|
||||
tl.to("#burn-c", { opacity: 1, x: -200, duration: 0.45, ease: "power1.in" }, T + 0.1);
|
||||
tl.set(old, { opacity: 0 }, T + 0.35);
|
||||
tl.set(new, { opacity: 1 }, T + 0.35);
|
||||
tl.to("#burn-a", { opacity: 0, duration: 0.3, ease: "power2.out" }, T + 0.45);
|
||||
tl.to("#burn-b", { opacity: 0, duration: 0.3, ease: "power2.out" }, T + 0.5);
|
||||
tl.to("#burn-c", { opacity: 0, duration: 0.3, ease: "power2.out" }, T + 0.55);
|
||||
```
|
||||
@@ -0,0 +1,30 @@
|
||||
## Mechanical
|
||||
|
||||
### Shutter
|
||||
|
||||
Two full-screen halves close from top and bottom, meet in the middle. Swap while closed. Open again.
|
||||
|
||||
```js
|
||||
tl.to("#shutter-top", { y: 0, duration: 0.25, ease: "power3.in" }, T);
|
||||
tl.to("#shutter-bot", { y: 0, duration: 0.25, ease: "power3.in" }, T);
|
||||
tl.set(old, { opacity: 0 }, T + 0.25);
|
||||
tl.set(new, { opacity: 1 }, T + 0.25);
|
||||
tl.to("#shutter-top", { y: -540, duration: 0.25, ease: "power3.out" }, T + 0.3);
|
||||
tl.to("#shutter-bot", { y: 540, duration: 0.25, ease: "power3.out" }, T + 0.3);
|
||||
```
|
||||
|
||||
### Clock Wipe
|
||||
|
||||
Radial polygon sweep stepping through quadrants. Use 9-point polygon with intermediate edge positions for smooth sweep.
|
||||
|
||||
```js
|
||||
tl.set(new, { opacity: 1, zIndex: 10 }, T);
|
||||
var d = 0.1; // duration per quadrant
|
||||
tl.set(new, { clipPath: "polygon(50% 50%, 50% 0%, 50% 0%, 50% 0%, 50% 0%, 50% 0%, 50% 0%, 50% 0%, 50% 0%)" }, T);
|
||||
tl.to(new, { clipPath: "polygon(50% 50%, 50% 0%, 100% 0%, 100% 50%, 100% 50%, 100% 50%, 100% 50%, 100% 50%, 100% 50%)", duration: d, ease: "none" }, T);
|
||||
tl.to(new, { clipPath: "polygon(50% 50%, 50% 0%, 100% 0%, 100% 50%, 100% 100%, 50% 100%, 50% 100%, 50% 100%, 50% 100%)", duration: d, ease: "none" }, T + d);
|
||||
tl.to(new, { clipPath: "polygon(50% 50%, 50% 0%, 100% 0%, 100% 50%, 100% 100%, 50% 100%, 0% 100%, 0% 50%, 0% 50%)", duration: d, ease: "none" }, T + d*2);
|
||||
tl.to(new, { clipPath: "polygon(50% 50%, 50% 0%, 100% 0%, 100% 50%, 100% 100%, 50% 100%, 0% 100%, 0% 50%, 0% 0%)", duration: d, ease: "none" }, T + d*3);
|
||||
tl.set(new, { clipPath: "none", zIndex: "auto" }, T + d*4 + 0.02);
|
||||
tl.set(old, { opacity: 0, zIndex: "auto" }, T + d*4 + 0.02);
|
||||
```
|
||||
@@ -0,0 +1,25 @@
|
||||
## Other
|
||||
|
||||
### Gravity Drop
|
||||
|
||||
Old scene falls down with slight rotation. New scene was behind it. Needs z-index.
|
||||
|
||||
```js
|
||||
tl.set(new, { opacity: 1, zIndex: 1 }, T);
|
||||
tl.set(old, { zIndex: 10 }, T);
|
||||
tl.to(old, { y: 1200, rotation: 4, duration: 0.5, ease: "power3.in" }, T);
|
||||
tl.set(old, { opacity: 0, zIndex: "auto" }, T + 0.5);
|
||||
tl.set(new, { zIndex: "auto" }, T + 0.5);
|
||||
```
|
||||
|
||||
### Morph Circle
|
||||
|
||||
A circle scales up from center to fill frame (becoming the new scene's background color). New scene content fades in on top.
|
||||
|
||||
```js
|
||||
tl.set("#morph-circle", { background: newBgColor, opacity: 1, scale: 0 }, T);
|
||||
tl.to("#morph-circle", { scale: 30, duration: 0.5, ease: "power3.in" }, T);
|
||||
tl.set(old, { opacity: 0 }, T + 0.4);
|
||||
tl.set(new, { opacity: 1 }, T + 0.4);
|
||||
tl.to("#morph-circle", { opacity: 0, duration: 0.15, ease: "power2.out" }, T + 0.5);
|
||||
```
|
||||
@@ -0,0 +1,41 @@
|
||||
## Linear / Push
|
||||
|
||||
### Push Slide
|
||||
|
||||
Both scenes move together — new pushes old out.
|
||||
|
||||
```js
|
||||
tl.to(old, { x: -1920, duration: 0.5, ease: "power3.inOut" }, T);
|
||||
tl.fromTo(new, { x: 1920, opacity: 1 }, { x: 0, duration: 0.5, ease: "power3.inOut" }, T);
|
||||
```
|
||||
|
||||
### Vertical Push
|
||||
|
||||
Same as push slide but vertical.
|
||||
|
||||
```js
|
||||
tl.to(old, { y: -1080, duration: 0.5, ease: "power3.inOut" }, T);
|
||||
tl.fromTo(new, { y: 1080, opacity: 1 }, { y: 0, duration: 0.5, ease: "power3.inOut" }, T);
|
||||
```
|
||||
|
||||
### Elastic Push
|
||||
|
||||
Push with overshoot bounce on the incoming scene.
|
||||
|
||||
```js
|
||||
tl.to(old, { x: -1920, duration: 0.5, ease: "power3.in" }, T);
|
||||
tl.fromTo(new, { x: 1920, opacity: 1 }, { x: 30, duration: 0.4, ease: "power4.out" }, T + 0.1);
|
||||
tl.to(new, { x: -15, duration: 0.15, ease: "sine.inOut" }, T + 0.5);
|
||||
tl.to(new, { x: 0, duration: 0.1, ease: "sine.out" }, T + 0.65);
|
||||
```
|
||||
|
||||
### Squeeze
|
||||
|
||||
Old compresses, new expands from opposite side.
|
||||
|
||||
```js
|
||||
tl.to(old, { scaleX: 0, transformOrigin: "left center", duration: 0.4, ease: "power3.inOut" }, T);
|
||||
tl.fromTo(new, { scaleX: 0, transformOrigin: "right center", opacity: 1 },
|
||||
{ scaleX: 1, duration: 0.4, ease: "power3.inOut" }, T + 0.1);
|
||||
tl.set(old, { opacity: 0 }, T + 0.5);
|
||||
```
|
||||
@@ -0,0 +1,37 @@
|
||||
## Radial / Shape
|
||||
|
||||
### Circle Iris
|
||||
|
||||
Expanding circle from center reveals new scene.
|
||||
|
||||
```js
|
||||
tl.set(new, { opacity: 1 }, T);
|
||||
tl.fromTo(new,
|
||||
{ clipPath: "circle(0% at 50% 50%)" },
|
||||
{ clipPath: "circle(75% at 50% 50%)", duration: 0.5, ease: "power2.out" }, T);
|
||||
tl.set(old, { opacity: 0 }, T + 0.5);
|
||||
```
|
||||
|
||||
### Diamond Iris
|
||||
|
||||
Expanding diamond shape from center.
|
||||
|
||||
```js
|
||||
tl.set(new, { opacity: 1 }, T);
|
||||
tl.fromTo(new,
|
||||
{ clipPath: "polygon(50% 50%, 50% 50%, 50% 50%, 50% 50%)" },
|
||||
{ clipPath: "polygon(50% -20%, 120% 50%, 50% 120%, -20% 50%)", duration: 0.5, ease: "power2.out" }, T);
|
||||
tl.set(old, { opacity: 0 }, T + 0.5);
|
||||
```
|
||||
|
||||
### Diagonal Split
|
||||
|
||||
Old scene shrinks to a triangle in one corner.
|
||||
|
||||
```js
|
||||
tl.set(new, { opacity: 1, zIndex: 1 }, T);
|
||||
tl.set(old, { zIndex: 10, clipPath: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)" }, T);
|
||||
tl.to(old, { clipPath: "polygon(60% 0%, 100% 0%, 100% 40%, 60% 0%)", duration: 0.5, ease: "power3.inOut" }, T);
|
||||
tl.set(old, { opacity: 0, zIndex: "auto", clipPath: "none" }, T + 0.5);
|
||||
tl.set(new, { zIndex: "auto" }, T + 0.5);
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
## Scale / Zoom
|
||||
|
||||
### Zoom Through
|
||||
|
||||
Old zooms past camera + blurs, new zooms in from behind.
|
||||
|
||||
```js
|
||||
tl.to(old, { scale: 2.5, opacity: 0, filter: "blur(8px)", duration: 0.4, ease: "power3.in" }, T);
|
||||
tl.fromTo(new,
|
||||
{ scale: 0.5, opacity: 0, filter: "blur(8px)" },
|
||||
{ scale: 1, opacity: 1, filter: "blur(0px)", duration: 0.4, ease: "power3.out" }, T + 0.15);
|
||||
```
|
||||
|
||||
### Zoom Out
|
||||
|
||||
Old shrinks away, new was behind it. Needs z-index management.
|
||||
|
||||
```js
|
||||
tl.set(new, { opacity: 1, zIndex: 1 }, T);
|
||||
tl.set(old, { zIndex: 10, transformOrigin: "50% 50%" }, T);
|
||||
tl.to(old, { scale: 0.3, opacity: 0, duration: 0.4, ease: "power3.in" }, T);
|
||||
tl.set(old, { zIndex: "auto" }, T + 0.4);
|
||||
tl.set(new, { zIndex: "auto" }, T + 0.4);
|
||||
```
|
||||
@@ -0,0 +1,153 @@
|
||||
# Scene Transitions
|
||||
|
||||
A transition tells the viewer how two scenes relate. A crossfade says "this continues." A push slide says "next point." A blur crossfade says "drift with me." Choose transitions that match what the content is doing emotionally, not just technically.
|
||||
|
||||
## Contents
|
||||
|
||||
- Animation rules for multi-scene compositions
|
||||
- Energy and mood transition selection
|
||||
- Narrative position
|
||||
- Blur intensity
|
||||
- Presets
|
||||
- Implementation
|
||||
- CSS vs shader guidance
|
||||
- Shader-compatible CSS rules
|
||||
- Visual pattern warnings
|
||||
|
||||
## Animation Rules for Multi-Scene Compositions
|
||||
|
||||
These are non-negotiable for every multi-scene composition:
|
||||
|
||||
1. **Every composition uses transitions.** No exceptions. Scenes without transitions feel like jump cuts.
|
||||
2. **Every scene uses entrance animations.** Elements animate IN — opacity, position, scale, etc. No scene should pop fully-formed onto screen. Use `gsap.fromTo()` (not `gsap.from()`) so the start state is explicit: `from()` animates _to_ current CSS, so pairing it with CSS `opacity: 0` is a 0→0 noop and the element never appears (see `/hyperframes-core` → sub-compositions).
|
||||
3. **Exit animations are BANNED** except on the final scene. Do NOT use `gsap.to()` to animate elements out before a transition fires. The transition IS the exit. Outgoing scene content must be fully visible when the transition starts — the transition handles the visual handoff.
|
||||
4. **Final scene exception:** The last scene MAY fade elements out (e.g., fade to black at the end of the composition). This is the only scene where exit animations are allowed.
|
||||
|
||||
```js
|
||||
// ❌ BANNED — fading the outgoing scene out, then the next scene just runs its entrance.
|
||||
// This is a jump cut with a dip, not a transition.
|
||||
tl.to("#s1", { opacity: 0, duration: 0.4 }, 4.0);
|
||||
tl.from("#s2 .headline", { y: 40, opacity: 0 }, 4.4);
|
||||
|
||||
// ✅ CORRECT — outgoing and incoming animate AT THE SAME TIME T; the motion IS the handoff.
|
||||
const T = 4.0;
|
||||
tl.to("#s1", { yPercent: -100, filter: "blur(8px)", duration: 0.5, ease: "power3.in" }, T);
|
||||
tl.fromTo("#s2", { yPercent: 100 }, { yPercent: 0, duration: 0.5, ease: "power3.out" }, T);
|
||||
```
|
||||
|
||||
> **You are NOT done after this file.** This overview gives you _which_ transition and _when_. Before writing any transition you MUST open **`catalog.md`** in this directory for the GSAP code and the hard rule every transition follows — _position new scene → animate outgoing → swap → animate incoming → clean up overlays_ — plus the per-category `css-*.md` files for specifics. Authoring transitions from this overview alone is how you end up shipping the ❌ pattern above.
|
||||
|
||||
## Energy → Primary Transition
|
||||
|
||||
| Energy | CSS Primary | Shader Primary | Accent | Duration | Easing |
|
||||
| ---------------------------------------- | ---------------------------- | ------------------------------------ | ------------------------------ | --------- | ---------------------- |
|
||||
| **Calm** (wellness, brand story, luxury) | Blur crossfade, focus pull | Cross-warp morph, thermal distortion | Light leak, circle iris | 0.5-0.8s | `sine.inOut`, `power1` |
|
||||
| **Medium** (corporate, SaaS, explainer) | Push slide, staggered blocks | Whip pan, cinematic zoom | Squeeze, vertical push | 0.3-0.5s | `power2`, `power3` |
|
||||
| **High** (promos, sports, music, launch) | Zoom through, overexposure | Ridged burn, glitch, chromatic split | Staggered blocks, gravity drop | 0.15-0.3s | `power4`, `expo` |
|
||||
|
||||
Pick ONE primary (60-70% of scene changes) + 1-2 accents. Never use a different transition for every scene.
|
||||
|
||||
## Mood → Transition Type
|
||||
|
||||
Think about what the transition _communicates_, not just what it looks like.
|
||||
|
||||
| Mood | Transitions | Why it works |
|
||||
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
|
||||
| **Warm / inviting** | Light leak, blur crossfade, focus pull, film burn · **Shader:** thermal distortion, light leak, cross-warp morph | Soft edges, warm color washes. Nothing sharp or mechanical. |
|
||||
| **Cold / clinical** | Squeeze, zoom out, blinds, shutter, grid dissolve · **Shader:** gravitational lens | Content transforms mechanically — compressed, shrunk, sliced, gridded. |
|
||||
| **Editorial / magazine** | Push slide, vertical push, diagonal split, shutter · **Shader:** whip pan | Like turning a page or slicing a layout. Clean directional movement. |
|
||||
| **Tech / futuristic** | Grid dissolve, staggered blocks, blinds, chromatic aberration · **Shader:** glitch, chromatic split | Grid dissolve is the core "data" transition. Shader glitch adds posterization + scan lines. |
|
||||
| **Tense / edgy** | Glitch, VHS, chromatic aberration, ripple · **Shader:** ridged burn, glitch, domain warp | Instability, distortion, digital breakdown. Ridged burn adds sharp lightning-crack edges. |
|
||||
| **Playful / fun** | Elastic push, 3D flip, circle iris, morph circle, clock wipe · **Shader:** ripple waves, swirl vortex | Overshoot, bounce, rotation, expansion. Swirl vortex adds organic spiral distortion. |
|
||||
| **Dramatic / cinematic** | Zoom through, zoom out, gravity drop, overexposure, color dip to black · **Shader:** cinematic zoom, gravitational lens, domain warp | Scale, weight, light extremes. Shader transitions add per-pixel depth. |
|
||||
| **Premium / luxury** | Focus pull, blur crossfade, color dip to black · **Shader:** cross-warp morph, thermal distortion | Restraint. Cross-warp morph flows both scenes into each other organically. |
|
||||
| **Retro / analog** | Film burn, light leak, VHS, clock wipe · **Shader:** light leak | Organic imperfection. Warm color bleeds, scan line displacement. |
|
||||
|
||||
## Narrative Position
|
||||
|
||||
| Position | Use | Why |
|
||||
| -------------------------- | -------------------------------------------------------------------------- | ----------------------------------------------------- |
|
||||
| **Opening** | Your most distinctive transition. Match the mood. 0.4-0.6s | Sets the visual language for the entire piece. |
|
||||
| **Between related points** | Your primary transition. Consistent. 0.3s | Don't distract — the content is continuing. |
|
||||
| **Topic change** | Something different from your primary. Staggered blocks, shutter, squeeze. | Signals "new section" — the viewer's brain resets. |
|
||||
| **Climax / hero reveal** | Your boldest accent. Fastest or most dramatic. | This is the payoff — spend your best transition here. |
|
||||
| **Wind-down** | Return to gentle. Blur crossfade, crossfade. 0.5-0.7s | Let the viewer exhale after the climax. |
|
||||
| **Outro** | Slowest, simplest. Crossfade, color dip to black. 0.6-1.0s | Closure. Don't introduce new energy at the end. |
|
||||
|
||||
## Blur Intensity by Energy
|
||||
|
||||
| Energy | Blur | Duration | Hold at peak |
|
||||
| ---------- | ------- | -------- | ------------ |
|
||||
| **Calm** | 20-30px | 0.8-1.2s | 0.3-0.5s |
|
||||
| **Medium** | 8-15px | 0.4-0.6s | 0.1-0.2s |
|
||||
| **High** | 3-6px | 0.2-0.3s | 0s |
|
||||
|
||||
## Presets
|
||||
|
||||
| Preset | Duration | Easing |
|
||||
| ---------- | -------- | ----------------- |
|
||||
| `snappy` | 0.2s | `power4.inOut` |
|
||||
| `smooth` | 0.4s | `power2.inOut` |
|
||||
| `gentle` | 0.6s | `sine.inOut` |
|
||||
| `dramatic` | 0.5s | `power3.in` → out |
|
||||
| `instant` | 0.15s | `expo.inOut` |
|
||||
| `luxe` | 0.7s | `power1.inOut` |
|
||||
|
||||
## Implementation
|
||||
|
||||
Read `catalog.md` in this directory for GSAP code and hard rules for every transition type, and the `css-*.md` files for per-category implementation details.
|
||||
|
||||
| Category | CSS | Shader (WebGL) |
|
||||
| ----------- | -------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| Push/slide | Push slide, vertical push, elastic push, squeeze | Whip pan |
|
||||
| Scale/zoom | Zoom through, zoom out, gravity drop, 3D flip | Cinematic zoom, gravitational lens |
|
||||
| Reveal/mask | Circle iris, diamond iris, diagonal split, clock wipe, shutter | SDF iris |
|
||||
| Dissolve | Crossfade, blur crossfade, focus pull, color dip | Cross-warp morph, domain warp |
|
||||
| Cover | Staggered blocks, horizontal blinds, vertical blinds | — |
|
||||
| Light | Light leak, overexposure burn, film burn | Light leak (shader), thermal distortion |
|
||||
| Distortion | Glitch, chromatic aberration, ripple, VHS tape | Glitch (shader), chromatic split, ridged burn, ripple waves, swirl vortex |
|
||||
| Pattern | Grid dissolve, morph circle | — |
|
||||
|
||||
## Transitions That Don't Work in CSS
|
||||
|
||||
Avoid: star iris, tilt-shift, lens flare, hinge/door. See catalog.md for why.
|
||||
|
||||
## CSS vs Shader
|
||||
|
||||
CSS transitions animate scene containers with opacity, transforms, clip-path, and filters. Shader transitions composite both scene textures per-pixel on a WebGL canvas — they can warp, dissolve, and morph in ways CSS cannot.
|
||||
|
||||
**Both are first-class options.** Shaders are provided by the `@hyperframes/shader-transitions` package — import from the package instead of writing raw GLSL. CSS transitions are simpler to set up. Choose based on the effect you want, not based on which is easier.
|
||||
|
||||
**Mixing is supported.** You can have some transitions use WebGL shaders and others use a CSS crossfade in the same composition. Omit the `shader` field on any `TransitionConfig` entry to get a smooth opacity crossfade instead of a WebGL effect:
|
||||
|
||||
```js
|
||||
var tl = HyperShader.init({
|
||||
bgColor: "#000",
|
||||
accentColor: "#6366f1",
|
||||
scenes: ["s1", "s2", "s3", "s4"],
|
||||
transitions: [
|
||||
{ time: 4.0, shader: "sdf-iris", duration: 0.7 }, // WebGL shader
|
||||
{ time: 8.5, duration: 0.8 }, // no shader → CSS crossfade
|
||||
{ time: 13.0, shader: "domain-warp", duration: 0.6 }, // WebGL shader
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
HyperShader manages all scene visibility regardless of transition type. Let it create the timeline (don't pass `timeline:` into `init()`) and add your beat animations to the returned `tl` after the call.
|
||||
|
||||
## Shader-Compatible CSS Rules
|
||||
|
||||
Shader transitions capture DOM scenes to WebGL textures via html2canvas. The canvas 2D rendering pipeline doesn't match CSS exactly. Follow these rules to avoid visible artifacts at transition boundaries:
|
||||
|
||||
1. **No `transparent` keyword in gradients.** Canvas interpolates `transparent` as `rgba(0,0,0,0)` (black at zero alpha), creating dark fringes. Always use the target color at zero alpha: `rgba(200,117,51,0)` not `transparent`.
|
||||
2. **No gradient backgrounds on elements thinner than 4px.** Canvas can't match CSS gradient rendering on 1-2px elements. Use solid `background-color` on thin accent lines.
|
||||
3. **No CSS variables (`var()`) on elements visible during capture.** html2canvas doesn't reliably resolve custom properties. Use literal color values in inline styles.
|
||||
4. **Mark uncapturable decorative elements with `data-no-capture`.** The capture function skips these. They're present on the live DOM but absent from the shader texture. Use for elements that can't follow the rules above.
|
||||
5. **No gradient opacity below 0.15.** Gradient elements below 10% opacity render differently in canvas vs CSS. Increase to 0.15+ or use a solid color at equivalent brightness.
|
||||
6. **Every `.scene` div must have explicit `background-color`, AND pass the same color as `bgColor` in the `init()` config.** The package captures scene elements via html2canvas. Both the CSS `background-color` on `.scene` and the `bgColor` config must match. Without either, the texture renders as black.
|
||||
|
||||
These rules only apply to shader transition compositions. CSS-only compositions have no restrictions.
|
||||
|
||||
## Visual Pattern Warning
|
||||
|
||||
Avoid transitions that create visible repeating geometric patterns — grids of tiles, hexagonal cells, uniform dot arrays, evenly-spaced blob circles. These look cheap and artificial regardless of the math behind them. Organic noise (FBM, domain warping) is good because it's irregular. Geometric repetition is bad because the eye instantly sees the grid.
|
||||
Reference in New Issue
Block a user