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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:35 +08:00
commit 85453da49f
4031 changed files with 710987 additions and 0 deletions
@@ -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: 38
- 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.01.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.040.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 1030px 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 1030px, 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: 23 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 ≈ 4060 % 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.22.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.30.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.42.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.40.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. 13 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.41.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.21.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.00.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.060.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.800.90; target 0.920.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.00.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.51.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: 36
- 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.060.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: 414 (glyph sets follow the word length; for fragments/cards stay 49)
- 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: 250700 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: 40110°
- 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.71.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.030.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: 1560° 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.51.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/(n1)`. 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.350.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.21.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.350.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** — 24 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.250.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: 40120% 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: 24
- 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: 1240 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.050.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.880.96; PHASE_2 0.981.02; PHASE_3 1.041.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.31.0 s; PHASE_2_DUR 1.01.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.04.0 s; PHASE_3_DUR 1.02.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: 13
- 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 28 px; DRIFT_AMP_Y 14 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.21.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: 300900 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.40.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: 425 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.40.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.060.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.22.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.21.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 24 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.20.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=DURATION0.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.60.7` (`../adapters/gsap-easing-and-stagger.md` → Spring Eases) gives ~510% 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** — 46 reads as "a trend" without clutter; the last bar is the current/accent value.
- **Fill duration** — 0.81.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.060.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.52.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: 410 (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: 24 over a 35 s comp
- Effects: ≥ 5 reads as anxious flicker; ≤ 1 reads as forgotten
- **PULSE_DOT_AMP** — dot scale amplitude
- Range: 0.050.20
- Effects: 0.05 = breathing; 0.20 = throbbing
- **PULSE_RING_AMP** — ring scale amplitude (typically lower than DOT_AMP)
- Range: 0.040.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.40.6; AMP 0.30.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.31.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`