[teamai] Push 87 resource(s) from XingfenD
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
---
|
||||
name: 3d-camera-flight
|
||||
description: Perspective camera FLIGHT through a 3D-laid-out world — one static perspective stage + preserve-3d world whose pose (translate3d + rotateX/rotateY) is tweened leg-by-leg from a single camera state object. Dive into an angled grid, tilt-to-flatten pull-back, continuous flight past standing cards, decelerate-into-focus. Hard power4.out landings, power2.inOut repositioning; DoF via depth-of-field-blur on non-focal planes.
|
||||
metadata:
|
||||
tags: camera, 3d, flight, perspective, preserve-3d, rotateX, rotateY, translateZ, dive, tilt, world, cinematic
|
||||
---
|
||||
|
||||
# 3D Camera Flight
|
||||
|
||||
Every other camera rule here is a **2D camera**: [viewport-change.md](viewport-change.md), [multi-phase-camera.md](multi-phase-camera.md), and [coordinate-target-zoom.md](coordinate-target-zoom.md) simulate the camera with `scale` + `translate` on a flat wrapper — the lens never tilts, and there is no depth axis to travel along. [3d-page-scroll.md](3d-page-scroll.md) is a **static tilt**: one angle held all scene while content scrolls inside. This rule is the missing camera that _flies_ — dives into an angled grid, pulls back while the world rotates flat, streaks past standing cards, decelerates out of a blur into focus: a **perspective camera traveling with `rotateX` / `rotateY` / `translateZ` through a 3D-laid-out world**, under the same single-camera discipline as `viewport-change`: **one perspective wrapper, one camera state object, one transform writer**, every leg a sequenced tween on that state.
|
||||
|
||||
## How It Works
|
||||
|
||||
Five layers, strictly separated:
|
||||
|
||||
1. **The lens** — `perspective: PERSPECTIVE_PX` on a static `.stage` wrapper. Set once, never tweened, never moved. Changing perspective mid-shot reads as the lens itself warping, not the camera moving.
|
||||
2. **The world** — a `.world` div with `transform-style: preserve-3d`, laid out at final 1× size: the ground surface (grid, form card, canvas) as flat DOM, optional **props** (a giant date number, a floating label) at static `translateZ(PROP_Z)` offsets so travel produces parallax, and **standing cards** counter-tilted to face the camera at their landing pose.
|
||||
3. **The camera state** — a single object `cam = { x, y, z, rx, ry }` (the world's pose), written to `world.style.transform` by ONE function, `applyCamera()`, in a **fixed order**: `translate3d(x, y, z) rotateX(rx) rotateY(ry)`. With translate composed _outside_ the rotations, `x`/`y`/`z` always move the world along **screen axes** no matter how it is currently tilted — pan is always sideways, `z` is always toward/away from the lens. Put the rotations first and every leg's numbers change meaning as the tilt changes.
|
||||
4. **The legs** — sequential tweens on `cam`, each one camera move: dive in (`power4.out` — violent arrival, sharp settle), tilt-to-flatten pull-back (`power2.inOut` — a repositioning, no slam), lateral flight, final dive. Camera intent inverts onto the world pose exactly as in `viewport-change`: camera flies **in** → world `z` **increases** (comes toward the lens); camera pans **right** → world `x` **negative**; camera tilts **down** over the surface → world `rx` **positive** (far edge tips away).
|
||||
5. **Depth cues** — DoF via [depth-of-field-blur.md](depth-of-field-blur.md) `--dof` tweens on the **non-focal planes** (cards, props — leaf elements, never the world itself), and velocity blur on travel legs via [motion-blur-streak.md](motion-blur-streak.md)'s Camera-Travel Carve-Out — applied to the **stage**, never the world (a `filter` on a `preserve-3d` element flattens it).
|
||||
|
||||
Landing poses are **authored, not derived**: set `cam` to candidate values at design time, call `applyCamera()`, screenshot, adjust, bake the numbers as constants. There is no counter-translate formula to get wrong in 3D — the pose IS the design decision. Never measure per-frame (`getBoundingClientRect` in `onUpdate` desyncs under parallel frame sampling), and don't hand-derive 3D projections — your eye at design time beats the math.
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<!-- The lens: static perspective, nothing else. -->
|
||||
<div class="stage">
|
||||
<!-- The world: preserve-3d, laid out at final 1× size; the camera flies by
|
||||
tweening THIS element's pose. Travel legs push content past the frame
|
||||
edges by design — hence data-layout-allow-overflow. -->
|
||||
<div class="world" id="world" data-layout-allow-overflow>
|
||||
<div class="surface">
|
||||
<div class="grid">{gridCells}</div>
|
||||
<div class="card layer" id="card-a" data-depth="0">{cardA}</div>
|
||||
<div class="card layer" id="card-b" data-depth="0">{cardB}</div>
|
||||
</div>
|
||||
<!-- Foreground props float at PROP_Z for parallax; they blur and fly past,
|
||||
never carry a read. -->
|
||||
<div class="prop layer" data-depth="2" style="--px: PROP_X; --py: PROP_Y">{propGlyph}</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
```css
|
||||
.scene {
|
||||
overflow: hidden; /* travel legs push world content past the frame on purpose */
|
||||
background: {sceneBg}; /* the void the flight exposes at frame edges — must be a
|
||||
designed surface (deep brand color / soft gradient), never default white */
|
||||
}
|
||||
.stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
perspective: PERSPECTIVE_PX; /* THE LENS — static, never tweened */
|
||||
/* travel blur (motion-blur-streak carve-out) attaches HERE, never on .world */
|
||||
}
|
||||
.world {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform-style: preserve-3d;
|
||||
transform-origin: 50% 50%;
|
||||
will-change: transform;
|
||||
/* keep CLEAN: no filter, opacity < 1, overflow, clip-path, or mask — each
|
||||
flattens preserve-3d. Background on .scene, blur on .stage or leaf cards. */
|
||||
}
|
||||
.surface {
|
||||
position: absolute;
|
||||
inset: WORLD_INSET; /* world runs larger than the frame so travel has runway */
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
.prop {
|
||||
position: absolute;
|
||||
left: var(--px);
|
||||
top: var(--py);
|
||||
/* static world-space pose; counter-tilt faces the camera at the dive pose */
|
||||
transform: translateZ(PROP_Z) rotateX(PROP_COUNTER_TILT);
|
||||
}
|
||||
.layer {
|
||||
--dof: 0px; /* DoF channel per depth-of-field-blur — leaf elements only */
|
||||
filter: blur(var(--dof));
|
||||
will-change: filter;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
const world = document.getElementById("world");
|
||||
|
||||
// Camera state — the ONLY source of truth for the world's pose. Every leg
|
||||
// tweens this object; nothing else touches world.style.transform.
|
||||
const cam = { x: 0, y: 0, z: WIDE_Z, rx: 0, ry: 0 };
|
||||
|
||||
function applyCamera() {
|
||||
// Fixed order: translate OUTSIDE the rotations → x/y/z stay screen-aligned
|
||||
// at any tilt. Changing this order changes what every baked pose means.
|
||||
world.style.transform = `translate3d(${cam.x}px, ${cam.y}px, ${cam.z}px) rotateX(${cam.rx}deg) rotateY(${cam.ry}deg)`;
|
||||
}
|
||||
applyCamera(); // seed frame 0 so a seek to t=0 renders the opening pose
|
||||
|
||||
// ── LEG 1 — DIVE IN: wide establishing pose → angled close-up on card A.
|
||||
// fromTo states the opening pose explicitly; power4.out = violent arrival,
|
||||
// razor-sharp settle. Travel blur: motion-blur-streak carve-out on .stage.
|
||||
const DIVE_POSE = { x: DIVE_X, y: DIVE_Y, z: DIVE_Z, rx: DIVE_RX, ry: DIVE_RY };
|
||||
tl.fromTo(
|
||||
cam,
|
||||
{ x: 0, y: 0, z: WIDE_Z, rx: 0, ry: 0 },
|
||||
{ ...DIVE_POSE, duration: DIVE_DUR, ease: "power4.out", onUpdate: applyCamera },
|
||||
DIVE_AT,
|
||||
);
|
||||
// Decelerate-INTO-FOCUS: non-focal planes' --dof ramps to BLUR_PER_DEPTH × data-depth
|
||||
// on the SAME window/ease (depth-of-field-blur focal pull); card A stays at --dof: 0.
|
||||
|
||||
// ── LEG 2 — TILT-TO-FLATTEN PULL-BACK: every channel returns to neutral on ONE
|
||||
// power2.inOut tween — a reposition, not a slam. DoF releases on the same window
|
||||
// so the flat overview arrives fully crisp.
|
||||
const FLAT_POSE = { x: 0, y: 0, z: 0, rx: 0, ry: 0 };
|
||||
tl.to(
|
||||
cam,
|
||||
{ ...FLAT_POSE, duration: FLATTEN_DUR, ease: "power2.inOut", onUpdate: applyCamera },
|
||||
FLATTEN_AT,
|
||||
);
|
||||
tl.to(".layer", { "--dof": "0px", duration: FLATTEN_DUR, ease: "power2.inOut" }, FLATTEN_AT);
|
||||
|
||||
// ── LEG 3 — LATERAL FLIGHT: screen-aligned pan (translate is outside the
|
||||
// rotations, so x is a pure sideways move even mid-tilt).
|
||||
tl.to(cam, { x: PAN_X, duration: PAN_DUR, ease: "power2.inOut", onUpdate: applyCamera }, PAN_AT);
|
||||
|
||||
// ── LEG 4 — FINAL DIVE onto card B: same grammar as leg 1; card A racks OUT of
|
||||
// focus as card B racks in (depth-of-field-blur rack, shared window).
|
||||
const LAND_POSE = { x: LAND_X, y: LAND_Y, z: LAND_Z, rx: LAND_RX, ry: LAND_RY };
|
||||
tl.to(
|
||||
cam,
|
||||
{ ...LAND_POSE, duration: LAND_DUR, ease: "power4.out", onUpdate: applyCamera },
|
||||
LAND_AT,
|
||||
);
|
||||
tl.to("#card-a", { "--dof": `${MAX_BLUR}px`, duration: LAND_DUR, ease: "power4.out" }, LAND_AT);
|
||||
tl.to("#card-b", { "--dof": "0px", duration: LAND_DUR, ease: "power4.out" }, LAND_AT);
|
||||
// Landing dwell: ≥1 s of stillness on card B — unless ending held mid-dive.
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Continuous flight past standing cards** — one long leg instead of dive-land-dive: sustained `z` + `x` travel (2–4 s, `power2.inOut` / `power1.inOut` near-constant cruise) through a corridor of cards and props at staggered `PROP_Z`. Parallax does the work — near props streak past while far ones crawl. Keep ONE plane sharp at a time via staggered `--dof` tweens. Props crossing the camera plane (`cam.z + PROP_Z` approaching `PERSPECTIVE_PX`) blow up to fill the frame and vanish — that IS the fly-past; never let a focal card cross it.
|
||||
- **End held mid-dive** — give the final leg a window that overruns the composition (`LAND_AT + LAND_DUR > data-duration`); the last frame holds mid-tween — still traveling, blur not fully resolved. Seek-safe by construction (a seek to the last frame lands at a deterministic pose); don't fake it with a shorter leg plus a manual offset. Use when the brief wants momentum at the cut, not rest.
|
||||
- **Whip sweep** — the heavily motion-blurred lateral whip that resolves into the next region: leg 3 driven by [nudge-curve.md](nudge-curve.md)'s three-phase chain (burst-dominant) on `cam.x`, with [motion-blur-streak.md](motion-blur-streak.md)'s Camera-Travel Carve-Out on the same window — blur ramps through the ramp-in, rides the burst at peak, resolves to 0 through the `power4.out` tail. Full recipe in that carve-out.
|
||||
- **Hold drift (the hold never dies)** — between legs, fold `multi-phase-camera`-style micro-drift **through the same writer**: a driver tween writes tiny `dx`/`dy`/`drx` into a `drift` object and `applyCamera()` composes `cam.x + drift.dx`, `cam.rx + drift.drx`, etc. Never let drift write `world.style.transform` itself — two writers on one transform is the classic camera bug. Amplitudes per `multi-phase-camera` (2–8 px), rotation drift ≤ 0.5°.
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| ------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| PERSPECTIVE_PX | 700–1400 px (moving cam best 800–1200) | smaller = wilder foreshortening, more violent dives; larger = near-orthographic, the flight flattens |
|
||||
| WORLD_INSET | −50% to −150% per side | world 2–4× the frame so lateral legs have runway |
|
||||
| PROP_Z | 80–300 px | higher = stronger parallax, earlier fly-past |
|
||||
| PROP_COUNTER_TILT | ≈ `-LAND_RX` of the leg that reads it | author by eye and bake |
|
||||
| DIVE_RX / LAND_RX | 30–55° | "angled grid" starts ~30°; \|rx\| ≤ ~65°, \|ry\| ≤ ~30° — beyond that flat planes go edge-on, text unreadable |
|
||||
| DIVE_Z / LAND_Z | 300–700 px at PERSPECTIVE_PX ≈ 1000 | **Z budget**: `cam.z + PROP_Z ≤ ~0.6 × PERSPECTIVE_PX` for readable content — near the perspective distance, scale blows toward infinity and elements invert/vanish past the camera plane |
|
||||
| WIDE_Z | −100 to −400 px | negative z = world pushed away = camera wide |
|
||||
| DIVE_X/Y, LAND_X/Y | read off a screenshot at the baked tilt | screen-aligned (translate outside rotations) |
|
||||
| DIVE_DUR / LAND_DUR | 0.6–1.0 s | commitment, not a polite zoom; under 0.5 s reads as a cut |
|
||||
| FLATTEN_DUR | 1.2–2.0 s | the repositioning is the breath between dives |
|
||||
| PAN_DUR | 0.8–1.5 s plain; 0.5–0.8 s whip | |
|
||||
| Ease law | `power4.out` dives/landings; `power2.inOut` repositioning/cruise | spring/back on a camera reads as the world wobbling on a string; four identical pushes read as a slideshow — vary the leg verbs |
|
||||
| Holds | ≥ 0.8 s between legs; final dwell ≥ 1 s | unless ending held mid-dive |
|
||||
| BLUR_PER_DEPTH / MAX_BLUR | per [depth-of-field-blur.md](depth-of-field-blur.md) | 3–6 px per step, terminal 8–24 px, leaf elements only; travel-blur peak per [motion-blur-streak.md](motion-blur-streak.md) (~18–20 px full-frame, on `.stage`) |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **One lens, one state, one writer** — `perspective` on the static `.stage` only (never on `.world`, never tweened, never a second perspective wrapper inside); every leg tweens the single `cam` object; only `applyCamera()` writes the transform — drift folds into the same writer via additive state. Two writers (or a second transform sneaking in via CSS) is the classic broken-camera bug, five channels of it here.
|
||||
- **Fixed transform order: translate outside the rotations** — `translate3d(x,y,z) rotateX() rotateY()`. Reorder it and every pose you authored silently means something else.
|
||||
- **Keep the world CLEAN** — `filter`, `opacity < 1`, `overflow` other than `visible`, `clip-path`, or `mask` on `.world` (or any intermediate wrapper) forces used `transform-style: flat` and collapses every `translateZ` in the scene. Travel blur goes on `.stage`; DoF on leaf cards; fades on children; background on `.scene`. `transform-style: preserve-3d` on `.world` and every intermediate wrapper between it and 3D-positioned children.
|
||||
- **Camera intent inverts onto the world** — fly in = world z up, pan right = world x negative, tilt down = world rx positive. Same sign law as `viewport-change`, two more axes to get right.
|
||||
- **Poses authored and baked** — never measured per-frame, never hand-derived projections.
|
||||
- **First leg is a `fromTo`** AND `applyCamera()` runs once at setup — a seek to t=0 must render the exact establishing pose.
|
||||
- **Z budget** — only sacrificial props may cross the camera plane.
|
||||
- **Reads happen at landings** — angled, blurred, flying text is texture; anything the viewer must read gets a near-flat pose or a sharp held close-up ≥ 1 s (the tilt-to-flatten leg exists to hand the surface over for reading).
|
||||
- **`overflow: hidden` on `.scene` + `data-layout-allow-overflow` on `.world`** — travel legs deliberately push panels past the frame; without the pairing, `check` reports `container_overflow` for every region the flight leaves behind.
|
||||
|
||||
## See also
|
||||
|
||||
[viewport-change.md](viewport-change.md) (2D counterpart, same single-writer law — right when the shot never tilts) · [multi-phase-camera.md](multi-phase-camera.md) (leg-sequencing grammar + hold micro-drift) · [coordinate-target-zoom.md](coordinate-target-zoom.md) (aim math for a flat-hold zoom while `rx`/`ry` are 0) · [depth-of-field-blur.md](depth-of-field-blur.md) (non-focal defocus / racks) · [motion-blur-streak.md](motion-blur-streak.md) (travel blur on the stage) · [nudge-curve.md](nudge-curve.md) (whip-sweep burst tuning) · [3d-page-scroll.md](3d-page-scroll.md) (static-tilt cousin — camera should NOT travel) · [orbit-3d-entry.md](orbit-3d-entry.md) / [depth-scatter-assemble.md](depth-scatter-assemble.md) (elements moving under a still camera — the inverse; don't run both on one beat). Capability background: `../techniques.md` § CSS 3D Transforms.
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
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. (For a camera that actually travels/tilts, see [3d-camera-flight.md](3d-camera-flight.md) — this rule's tilt never moves.)
|
||||
|
||||
## 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 (`y` in GSAP) within a clipped container; spring-like deceleration via `power3.out` / `power4.out`.
|
||||
|
||||
Optional: **spotlight overlay** — a radial-gradient mask dims everything except a focal region after the scroll lands. It sits above the scrolling content, fixed relative to the card, never inside `.page-content`.
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<div class="tilt-card">
|
||||
<div class="page-content">
|
||||
<!-- Full {Brand} webpage recreation, taller than the card so scrolling
|
||||
matters. Each section is REAL DOM, not a screenshot — screenshots
|
||||
can't be individually highlighted or scrolled-to with precision. -->
|
||||
<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>
|
||||
```
|
||||
|
||||
```css
|
||||
.tilt-card {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
/* tilt + perspective 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() instead 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 at the rounded corners */
|
||||
/* 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 intrinsic from sections — taller than the card */
|
||||
}
|
||||
.spotlight {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
background: radial-gradient(ellipse 60% 35% at 50% 50%, transparent 50%, {spotlightDimColor} 100%);
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// SCROLL_DISTANCE is measured at design time from the real page layout
|
||||
// (top of .page-content origin to vertical center of #target-section,
|
||||
// accounting for card height) — NOT a free tunable.
|
||||
tl.to(
|
||||
".page-content",
|
||||
{ y: -SCROLL_DISTANCE, duration: SCROLL_DUR, ease: "power3.out" },
|
||||
SCROLL_AT,
|
||||
);
|
||||
|
||||
// Spotlight fades in on the target after the scroll settles.
|
||||
tl.to(
|
||||
".spotlight",
|
||||
{ opacity: 1, duration: SPOTLIGHT_FADE_DUR, ease: "power1.inOut" },
|
||||
SPOTLIGHT_AT,
|
||||
);
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
**Multi-step scroll (scroll → pause → scroll)** — multiple `y:` tweens at different positions. Distances are both measured from the `.page-content` origin (NOT delta from the previous step); GSAP composes successive `y:` tweens on the same property, each starting from the value the previous one left:
|
||||
|
||||
```js
|
||||
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,
|
||||
);
|
||||
// SCROLL_AT_A + SCROLL_DUR ≤ SCROLL_AT_B — the two scrolls must not fight for y
|
||||
```
|
||||
|
||||
## Values
|
||||
|
||||
| token | range / rule | notes |
|
||||
| ------------------ | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
|
||||
| tiltYDeg | −12 to −4 (left-leaning) or 4 to 12 | bigger = more dramatic 3D; near 0 collapses to a flat panel |
|
||||
| tiltXDeg | 0–6 | positive tilts the top edge away |
|
||||
| perspectivePx | 800–2000 px | smaller = more foreshortening; larger = nearly orthographic |
|
||||
| cardWidth / Height | card height < total content height | otherwise the scroll has nothing to reveal |
|
||||
| sectionHeight | Σ heights ≥ cardHeight + SCROLL_DISTANCE | so the target section lands within frame |
|
||||
| SCROLL_AT | ≥ end of prior tweens on `.page-content` | |
|
||||
| SCROLL_DUR | 0.8–1.8 s | shorter feels like a hard cut; longer feels programmatic |
|
||||
| SCROLL_DISTANCE | measured from the layout | from actual cumulative section heights — never estimated; don't overshoot content end |
|
||||
| SPOTLIGHT_AT | ≥ SCROLL_AT + SCROLL_DUR (or slightly earlier) | spotlight reveals the freshly-arrived section |
|
||||
| SPOTLIGHT_FADE_DUR | 0.4–0.8 s | |
|
||||
| Ease | `power3.out` default; `power4.out` momentum; `power2.inOut` cinematic pan | pick ONE for all scrolls in the scene — mixing easings reads as jerky |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Tilt is static** — the card holds its angle the whole scene.
|
||||
- **Shadow direction matches tilt** — a left-leaning card casts shadow to the right (positive X offset); mismatch breaks the 3D illusion.
|
||||
- **Page content is real HTML, not a screenshot**; scroll distances come from the real layout geometry.
|
||||
- **`overflow: hidden` + `transform-style: preserve-3d` on `.tilt-card`** — clip at the rounded corners; preserve-3d for any 3D children / clean perspective composition.
|
||||
- **Spotlight is an overlay above the scrolling content**, never inside `.page-content`.
|
||||
- **Same easing across a multi-phase scroll**, and non-overlapping scroll windows.
|
||||
|
||||
## See also
|
||||
|
||||
[asr-keyword-glow.md](asr-keyword-glow.md) (on-page keyword highlight synced to VO) · [multi-phase-camera.md](multi-phase-camera.md) (camera zoom while the page scrolls) · [cursor-click-ripple.md](cursor-click-ripple.md) (cursor lands in the scrolled-into-view section) · [3d-camera-flight.md](3d-camera-flight.md) (when the camera itself should travel).
|
||||
@@ -0,0 +1,128 @@
|
||||
---
|
||||
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
|
||||
|
||||
The same text rendered N times at increasing offsets — back layers translucent, front layer full opacity and brand color — creates a physical "stacked extrusion" depth illusion on large typography. Distinct from `text-shadow` (which can't have per-layer hue / opacity / animation): each layer is a real DOM element.
|
||||
|
||||
## How It Works
|
||||
|
||||
A build script appends `LAYER_COUNT` copies back-to-front; each back layer sits at `translate(i × OFFSET_X, i × OFFSET_Y)` with alpha stepping down per layer, while the front copy (`i = 0`) is `position: relative` so it defines the container size (back layers stack absolutely behind it). The default entrance cascades the layers' fades back-to-front while a proxy tween grows the offsets from 0 → full, so the depth "builds forward" and lands as the last layer fades in.
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<!-- inside a standard scene clip (hyperframes-core) -->
|
||||
<div class="depth-stack">
|
||||
<!-- layers injected by script — LAYER_COUNT copies of {label} -->
|
||||
</div>
|
||||
```
|
||||
|
||||
```css
|
||||
.depth-stack {
|
||||
position: relative; /* front layer defines size; back layers stack behind */
|
||||
}
|
||||
.depth-text {
|
||||
font-weight: 900; /* black weight — thin text loses the illusion */
|
||||
font-size: HERO_FONT_SIZE;
|
||||
letter-spacing: HERO_LETTER_SPACING;
|
||||
line-height: 1;
|
||||
color: {frontColor};
|
||||
}
|
||||
.depth-text.is-back {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none; /* decorative */
|
||||
}
|
||||
.depth-text.is-front {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
const stack = document.querySelector(".depth-stack");
|
||||
|
||||
// Build back-to-front so the FRONT (i=0) is appended LAST
|
||||
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 = "{label}";
|
||||
if (i > 0) {
|
||||
const alpha = Math.max(BACK_ALPHA_MAX - i * BACK_ALPHA_STEP, BACK_ALPHA_MIN);
|
||||
el.style.color = `rgba({backHueRGB}, ${alpha})`; // rgba in color, NOT element opacity
|
||||
el.style.transform = `translate(${i * OFFSET_X}px, ${i * OFFSET_Y}px)`;
|
||||
}
|
||||
el.dataset.layer = String(i);
|
||||
stack.appendChild(el);
|
||||
}
|
||||
|
||||
// Cascade entry — back layers fade in first, building forward
|
||||
stack.querySelectorAll(".depth-text").forEach((el) => {
|
||||
const i = Number(el.dataset.layer);
|
||||
const finalAlpha = i === 0 ? 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,
|
||||
);
|
||||
});
|
||||
|
||||
// Depth grows on entry — offsets interpolate 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);
|
||||
el.style.transform = `translate(${i * OFFSET_X * depthState.p}px, ${i * OFFSET_Y * depthState.p}px)`;
|
||||
});
|
||||
},
|
||||
},
|
||||
LAYER_CASCADE_START, // align with the cascade so depth lands as the last layer fades in
|
||||
);
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Static depth** (single hero shot) — render all layers at final positions from t=0; optionally fade the whole stack in with a subtle scale (0.94–0.98 → 1, 0.5–0.8s).
|
||||
- **Dynamic depth pulse** — after the grow completes, modulate the offsets with a sine multiplier `1 + sin(p) × BEAT_AMP` (BEAT_AMP 0.2–0.6; one beat per 0.7–1.5s reads as a heartbeat).
|
||||
- **Color-shift back layers** — instead of fading to translucent, step hue/lightness per layer: `hsla(HUE_BASE − i × HUE_STEP, SAT_PCT%, LIGHT_BASE − i × LIGHT_STEP%, 1)` (HUE_STEP 4–12°; larger reads as glitch). Depth reads as a colored cast shadow.
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| ------------------- | ------------------------ | ---------------------------------------------------------------------- |
|
||||
| LAYER_COUNT | 4–6 | <4 doesn't read as 3D; >6 clutters on tight kerning |
|
||||
| OFFSET_X / OFFSET_Y | 1–3px each | >4px reads as glitch / chromatic aberration, not depth |
|
||||
| BACK_ALPHA_MAX | 0.6–0.85 | nearest back layer; >0.9 fights the front for dominance |
|
||||
| BACK_ALPHA_STEP | 0.08–0.15 | small = soft gradient; large = discrete plates |
|
||||
| BACK_ALPHA_MIN | 0.1–0.2 | floor — below 0.1 the deepest layer vanishes on dark backgrounds |
|
||||
| HERO_FONT_SIZE | 60px min; 200–340px hero | thin/small text loses the layered illusion |
|
||||
| HERO_LETTER_SPACING | −0.03em–0 | tighter makes offsets read as depth, not repetition |
|
||||
| LAYER_CASCADE_STEP | 0.04–0.10s | smaller ≈ simultaneous; larger feels stepped |
|
||||
| LAYER_FADE_DUR | 0.3–0.6s | per-layer fade |
|
||||
| DEPTH_GROW_DUR | 0.4–0.8s | ≈ `LAYER_FADE_DUR × LAYER_COUNT / 2` so depth lands with the last fade |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Offset direction implies light direction** — `(+x, +y)` = light upper-left, `(-x, +y)` = upper-right; one sign convention for the whole composition.
|
||||
- **Back layers translucent OR darker — never more saturated than the front** (reads as a halo, not depth).
|
||||
- **Set back-layer color via `rgba()` in `color`, not element `opacity`** — opacity fades the whole rendered glyph including any shadow.
|
||||
- **Front layer `position: relative` defines container size**; back layers absolute with `pointer-events: none`; offsets via `transform: translate()`, never `top`/`left`.
|
||||
- **No CSS `text-shadow` alongside layered depth** — they compound and over-extrude.
|
||||
- **No per-letter animation on top of the stack** — hacker-flip / typewriter over 6-layer depth is chaos; drop to 2–3 layers or apply depth only to the static post-reveal state.
|
||||
|
||||
## See also
|
||||
|
||||
`counting-dynamic-scale` (counter rendered with depth layers) · `sine-wave-loop` (idle breathing on the front layer post-reveal) · `center-outward-expansion` (depth-stacked wordmark after the burst lands).
|
||||
@@ -0,0 +1,139 @@
|
||||
---
|
||||
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 of four L-bracket corners + a confidence label that follows a moving target, simulating real-time AI detection. Rendered in detection yellow (`#facc15` family) on a dark background — the industry convention (AV HUDs, security CV, ML demos); red reads "warning", green "success", blue "info" — none read "detection."
|
||||
|
||||
## How It Works
|
||||
|
||||
ONE `ease: "none"` driver tween advances a phase `p`; its `onUpdate` computes the TARGET's position from trig, then derives the box's position/size FROM the target — every frame, in that order. The box never gets its own position tween: if it trails the target it reads as a broken tracker, not a smart AI. Size jitters a few percent off-tempo (non-integer frequency multiple) to mimic continuous re-fitting, and the confidence label flickers inside [95, 99].
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<!-- inside a standard scene clip (hyperframes-core) -->
|
||||
<div class="bg-mascot" id="mascot">{targetGlyph}</div>
|
||||
<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">{LABEL} · {confidence}%</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
```css
|
||||
.track-box {
|
||||
position: absolute; /* position + size written by the driver's onUpdate */
|
||||
pointer-events: none;
|
||||
will-change: transform, width, height;
|
||||
}
|
||||
.corner {
|
||||
position: absolute;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
/* Each corner draws only its two outer borders — .tr/.bl/.br mirror this: */
|
||||
.corner.tl {
|
||||
top: -8px;
|
||||
left: -8px;
|
||||
border-top: 6px solid {detectionYellow};
|
||||
border-left: 6px solid {detectionYellow};
|
||||
}
|
||||
.label {
|
||||
position: absolute;
|
||||
top: -56px;
|
||||
left: -8px;
|
||||
background: {detectionYellow};
|
||||
color: {labelTextColor}; /* near-black on yellow */
|
||||
font-family: {monoFont}; /* mono = machine readout */
|
||||
white-space: nowrap;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
const box = document.getElementById("track-box");
|
||||
const mascot = document.getElementById("mascot");
|
||||
const label = document.getElementById("label");
|
||||
const C = { x: COMP_WIDTH / 2, y: COMP_HEIGHT / 2 };
|
||||
|
||||
// Entry — the AI "locks on"
|
||||
gsap.set(box, { opacity: 0, scale: ENTRY_SCALE });
|
||||
tl.to(
|
||||
box,
|
||||
{ opacity: 1, scale: 1, duration: ENTRY_DUR, ease: `back.out(${ENTRY_BOUNCE})` },
|
||||
ENTRY_START,
|
||||
);
|
||||
|
||||
// Tracking — target first, box derived from it, every frame
|
||||
const tracking = { p: 0 };
|
||||
tl.to(
|
||||
tracking,
|
||||
{
|
||||
p: Math.PI * 2 * CYCLES,
|
||||
duration: TRACK_DUR,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
const mx = C.x + Math.cos(tracking.p) * DRIFT_X;
|
||||
const my = C.y + Math.sin(tracking.p) * DRIFT_Y;
|
||||
mascot.style.left = `${mx - MASCOT_SIZE / 2}px`;
|
||||
mascot.style.top = `${my - MASCOT_SIZE / 2}px`;
|
||||
|
||||
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.style.width = `${w}px`;
|
||||
box.style.height = `${h}px`;
|
||||
box.style.left = `${mx - w / 2}px`;
|
||||
box.style.top = `${my - h / 2}px`;
|
||||
|
||||
const conf = Math.round(
|
||||
CONFIDENCE_MEAN + Math.sin(tracking.p * CONFIDENCE_FREQ_MULT) * CONFIDENCE_VAR,
|
||||
);
|
||||
label.textContent = `${LABEL_TEXT} · ${conf}%`;
|
||||
},
|
||||
},
|
||||
TRACK_START,
|
||||
);
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Multi-object**: one driver per box/target pair, phases offset by `π / N` so they don't tick synchronously.
|
||||
- **Lost-then-reacquired**: fade the box to ~0.2–0.4 opacity, then re-snap with a harder `back.out(1.8–2.5)` and flash a "REACQUIRED · 99%" label via `tl.set`.
|
||||
- **Tracking-then-zoom**: hand off to [viewport-change.md](viewport-change.md) — "the AI found something, now show it."
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| -------------------- | ------------------ | ------------------------------------------------------------------------ |
|
||||
| ENTRY_SCALE | 0.5–0.9 | < 1 — the box snaps UP into focus |
|
||||
| ENTRY_DUR / \_BOUNCE | 0.3–0.8s / 1.2–2.5 | `back.out` only — elastic reads cartoonish, power reads flat |
|
||||
| TRACK_START | ≥ entry end | a gap = pause for emphasis; none = seamless lock + follow |
|
||||
| TRACK_DUR | 2–8s | ≥ one full cycle or the drift never reads as oscillation |
|
||||
| CYCLES | 0.5–3 | keep effective rate < ~0.6 Hz or the motion blurs |
|
||||
| DRIFT_X / DRIFT_Y | 40–200px | center ± drift must keep the target fully on screen |
|
||||
| SIZE_BASE | 200–500px | must visibly enclose the target at all jitter sizes |
|
||||
| SIZE_VAR | 5–10% of SIZE_BASE | more reads broken, none reads like a screenshot; keep < 0.15× |
|
||||
| SIZE_FREQ_MULT | 1.5–3, non-integer | integer ratios pulse in lock-step with drift = mechanical |
|
||||
| CONFIDENCE_MEAN/VAR | 95–99 / 1–3 | mean ± var ⊂ [95, 99]; < 95 "uncertain", 100 "fake-precise"; 97 is sweet |
|
||||
| CONFIDENCE_FREQ_MULT | 3–6 | > SIZE_FREQ_MULT — label flickers faster than the box breathes |
|
||||
| MASCOT_SIZE | = rendered size | mismatch drifts the target out of the box |
|
||||
|
||||
Tokens: `{detectionYellow}` `#facc15` family; `{bgInner}/{bgOuter}` dark low-chroma radial so the yellow pops; `{labelTextColor}` near-black; `{monoFont}` for the label.
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **❗ Box recomputed per-frame FROM the target** — one driver computes the target position, then the box derives from it in the same `onUpdate`. Never tween the box's position separately.
|
||||
- **Corner L-brackets, not a full border** — the genre signature; a full border reads as a generic UI box.
|
||||
- **Yellow-on-dark** — substituting another hue loses genre legibility.
|
||||
- **Confidence flickers in a tight band inside [95, 99]**, in a mono font.
|
||||
- **`pointer-events: none`** on the box — it's a decorative overlay.
|
||||
|
||||
## See also
|
||||
|
||||
`viewport-change` (zoom into the detection) · `multi-phase-camera` (wide during tracking, push-in on lock) · `sine-wave-loop` (the target idle-breathes inside the box).
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
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 blooms on the hero's settle and stays lit. Two forms: a **hero bloom** that swells behind a settling element then breathes, and a **traveling sweep** that translates a soft highlight across a surface exactly once.
|
||||
|
||||
## How It Works
|
||||
|
||||
A radial-gradient layer sits **behind** the hero (glow `z-index: 1`, hero `z-index: 2` — a glow in front occludes it), starting at `opacity: 0`. Over the bloom-in window it ramps `opacity: 0 → peak` with a gentle `scale` swell, timed so `BLOOM_START + BLOOM_DUR` lands on the hero's settle — glow and hero resolve as ONE beat ("powering on"), never glow-then-card. After bloom-in:
|
||||
|
||||
1. **Hero bloom** — a **bounded idle breathe** during the hold: a finite `ease: "none"` tween advances a `phase` proxy and `onUpdate` nudges opacity + scale a hair around peak (never a `yoyo` loop). `sin(0) = 0` → the breathe starts exactly at the bloom's resting state.
|
||||
2. **Traveling sweep** — a narrow highlight band at one edge translates **once** across to the other (`x` off-surface to off-surface), clipped to the surface (`overflow: hidden`). One pass, no return — a repeating sweep reads as a loading shimmer, not a reveal accent (the shimmer-sweep variation below is the sanctioned exception).
|
||||
|
||||
Peak opacity stays restrained (**≤ 0.45 hard ceiling**) so the glow gives presence without washing the frame; the glow color is **darker + more saturated** than the element it backs (a same-hue, same-lightness glow disappears into the surface).
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<!-- inside a standard scene clip -->
|
||||
<div class="bloom-stage">
|
||||
<div class="bloom-glow" id="bloom-glow"></div>
|
||||
<!-- z-index: 1; inset: GLOW_INSET (negative); background: {glowGradient} -->
|
||||
<div class="hero-card" id="hero-card">{HeroLabel}</div>
|
||||
<!-- z-index: 2 -->
|
||||
</div>
|
||||
<!-- sweep form: <div class="sweep" id="sweep"> inside the overflow:hidden surface -->
|
||||
```
|
||||
|
||||
```js
|
||||
// ── Form A: HERO BLOOM ── bloom in soft, landing on the hero's settle.
|
||||
tl.fromTo(
|
||||
"#bloom-glow",
|
||||
{ opacity: 0, scale: GLOW_START_SCALE },
|
||||
{ opacity: GLOW_PEAK_OPACITY, scale: 1, duration: BLOOM_DUR, ease: "power2.out" },
|
||||
BLOOM_START,
|
||||
);
|
||||
// Bounded breathe during the hold — finite phase tween, NOT a yoyo loop.
|
||||
const glow = document.getElementById("bloom-glow");
|
||||
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,
|
||||
);
|
||||
|
||||
// ── Form B: TRAVELING SWEEP ── one finite pass, constant glide.
|
||||
tl.fromTo(
|
||||
"#sweep",
|
||||
{ x: SWEEP_START_X, opacity: 0 },
|
||||
{ x: SWEEP_END_X, opacity: SWEEP_PEAK_OPACITY, duration: SWEEP_DUR, ease: "none" },
|
||||
SWEEP_START,
|
||||
);
|
||||
tl.to("#sweep", { opacity: 0, duration: SWEEP_FADE_DUR, ease: "power1.in" }, SWEEP_FADE_START);
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Bloom-and-hold** — for scenes <3s or a hero with its own idle, skip the breathe: the single `fromTo` is the whole recipe.
|
||||
- **Pulse-on-arrival** — bloom slightly PAST peak (`GLOW_OVERSHOOT_OPACITY`, `scale: 1.06`), then a second adjacent tween eases down to a steady hold — one breath punctuating the landing, no ongoing loop.
|
||||
- **Multi-hero relay** — stagger per-glow `BLOOM_START` by ~0.15–0.3s across a row; shrink `OPACITY_AMP` / `SCALE_AMP` per the `/√N` rule below.
|
||||
- **Diagonal raked sweep** — angle `{sweepGradient}` (~105°) across a wordmark: the classic one-pass logo sheen. Narrower `SWEEP_WIDTH`, higher `SWEEP_PEAK_OPACITY`.
|
||||
|
||||
### Shimmer sweep (text-clipped status-phrase working-state)
|
||||
|
||||
The sweep re-aimed **inside type**: a soft highlight gradient clipped into a status phrase ("Thinking…", "Analyzing dataset…") via `background-clip: text` travels left→right through the letterforms — the grey-on-grey shimmer that says _still working_. Unlike every other form here it legitimately **repeats while the status is live**: the repetition is diegetic working-state, not idle wobble (same defense as a blinking caret — the motion performs status). Two things keep it honest: it is **bounded** (one finite tween whose pass count is computed from the status window, never `repeat: -1`), and it is **killed at resolve** — the moment the status completes, the shimmer stops dead; a shimmer surviving into the answer beat turns a working indicator into decoration.
|
||||
|
||||
```js
|
||||
// Status shimmer — N passes as ONE bounded tween. Killed at resolve.
|
||||
const status = document.getElementById("status-phrase");
|
||||
// CSS on #status-phrase: background: {shimmerGradient}; background-size: 300% 100%;
|
||||
// -webkit-background-clip: text; background-clip: text; color: transparent;
|
||||
const shimmer = { p: 0 };
|
||||
const PASSES = Math.round(STATUS_DUR / PASS_PERIOD); // whole passes, computed up front
|
||||
tl.to(
|
||||
shimmer,
|
||||
{
|
||||
p: PASSES,
|
||||
duration: STATUS_DUR,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
const t = shimmer.p % 1; // 0→1 within each pass; percent axis inverted → left→right travel
|
||||
status.style.backgroundPosition = `${(1 - t) * 100}% 50%`;
|
||||
},
|
||||
},
|
||||
STATUS_START,
|
||||
);
|
||||
tl.set(status, { backgroundPosition: "100% 50%" }, STATUS_START + STATUS_DUR); // resolve: dead.
|
||||
```
|
||||
|
||||
Keep it a whisper: `{shimmerGradient}` is the status text's own grey with one slightly-lighter band (highlight stop a step above the base, nothing near white); `background-size` ~300% keeps the band narrow in the glyphs; `PASS_PERIOD` 1.2–1.8s — slower reads as a sheen accent, faster as a spinner. Whole-number `PASSES` lands the band at its start position exactly at the kill frame, so the `tl.set` is visually a no-op. This is the working-state cousin of `gradient-text-sweep`: reach **here** when the sweep _means_ "in progress," **there** when the gradient is the typographic treatment itself.
|
||||
|
||||
## Values
|
||||
|
||||
| token | range / default | notes |
|
||||
| ----------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------- |
|
||||
| GLOW_PEAK_OPACITY | 0.15 (subtle) → 0.30 (default) → **0.45 hard ceiling** | higher washes the frame; a glow you consciously notice is too strong |
|
||||
| GLOW_INSET | −200 to −450px (1920×1080) | negative so the halo extends past the hero; too small reads as a tight rim |
|
||||
| GLOW_START_SCALE | 0.80–1.0 | ≤1.0 — grow into place, never shrink |
|
||||
| BLOOM_DUR / BLOOM_START | 0.6–1.4s | `BLOOM_START + BLOOM_DUR` ≈ the hero's settle frame |
|
||||
| OPACITY_AMP / SCALE_AMP | 0.02–0.05 / 0.01–0.03 default | `PEAK + OPACITY_AMP ≤ 0.45`; push only when the glow is the sole motion |
|
||||
| BREATHE_CYCLES | period 2.5–4s per breath | glow breathes slower than element breathing |
|
||||
| SWEEP_WIDTH | 15–35% of surface (grid) / 8–15% (wordmark) | |
|
||||
| SWEEP_DUR | 0.8–1.6s | one deliberate pass — slow enough to read as light |
|
||||
| SWEEP_PEAK_OPACITY | 0.10 → 0.25 (default) → 0.40 | same ≤ ~0.45 wash limit; tight sweeps tolerate the high end |
|
||||
| SWEEP_START_X / END_X | fully off-surface both ends | no visible spawn/despawn mid-surface; fade reaches 0 as the band clears |
|
||||
| PASS_PERIOD (shimmer) | 1.2–1.8s | with whole-number PASSES |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Glow peak opacity ≤ 0.45** — including breathe amplitude; default to the LOW end (0.15–0.30).
|
||||
- **Glow behind, hero in front**; glow color darker + more saturated than the hero surface.
|
||||
- **Land glow and hero as one beat** — before or after reads as two separate events.
|
||||
- **Breathe is bounded, sweep is one pass** — the only sanctioned repetition is the shimmer sweep, bounded and killed at resolve.
|
||||
- **Concurrent halos compound** — per-glow amps ≤ default `/√N`, stagger breathe periods (2.6s / 2.9s / 3.3s) so they don't pulse in lockstep.
|
||||
- **Don't combine a `boxShadow` glow on the hero with this halo layer** — they compete and read muddy; the glow lives on the dedicated layer.
|
||||
|
||||
## See also
|
||||
|
||||
`sine-wave-loop` (hero breathes on scale/y while the glow breathes on opacity, out of phase) · `press-release-spring` (the click-triggered sibling — never both behind one element) · `counting-dynamic-scale` / `stat-bars-and-fills` (bloom behind a landing stat) · `center-outward-expansion` (sweep across the assembled grid) · `gradient-text-sweep` (the design-beat gradient counterpart).
|
||||
@@ -0,0 +1,148 @@
|
||||
---
|
||||
name: anchored-layout-expand
|
||||
description: Edge-pinned container grows (or collapses) along ONE axis and in-flow content reflows with it — a pill springs open downward into a dropdown, a panel grows a sub-task stack, an input card stretches as typed text wraps, a pane expands over a neighbor. Transform-only (mask + slide, or proxy-driven scaleY + counter-scale) because width/height tweens are forbidden; the push on subsequent content is a matched translate on the same tween.
|
||||
metadata:
|
||||
tags: expand, collapse, anchored, dropdown, menu, accordion, panel, reflow, push, mask, counter-scale, layout
|
||||
---
|
||||
|
||||
# Anchored Layout Expand
|
||||
|
||||
> The law: **author the layout at its final (expanded) state in CSS, then fake the collapsed state with transforms.** The container never changes size — the _visible_ region does — and everything downstream rides a matched translate. The browser computes layout ONCE; every intermediate frame is pure transform.
|
||||
|
||||
THE one-axis growth primitive: a container pinned at one edge appears to grow along a single axis, and the in-flow content after it moves in perfect contact with the traveling edge — dropdown, sub-task stack, growing composer card, pane widening over a neighbor. Growth and push are ONE motion: if the panel's bottom edge and the pushed content ever separate or overlap, the illusion dies.
|
||||
|
||||
Distinct from [card-morph-anchor.md](card-morph-anchor.md) (a free-floating two-shot morph with no neighbors to push — this rule's container is a live layout participant), [spring-pop-entrance.md](spring-pop-entrance.md) (arrival at a point, no edge travel or reflow), and [reactive-displacement.md](reactive-displacement.md) (displacement by a colliding intruder; here content moves because the container's edge reached it — layout causality, not collision).
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Mask** — a wrapper at the final body height (`BODY_H`), `overflow: hidden`. Never tweened.
|
||||
2. **Sheet** — the panel surface + content inside the mask, starting at `y: -BODY_H` (tucked above the mask window, behind the pinned header).
|
||||
3. **Below** — ONE wrapper holding everything after the container, also starting at `y: -BODY_H`.
|
||||
4. **Grow** — ONE `fromTo` drives sheet AND below from `y: -BODY_H → 0`. Shared tween ⇒ the descending bottom edge and the pushed content stay in exact contact by construction. Collapse = the same pair tweened back.
|
||||
|
||||
When the surface must visibly **stretch in place** (rows revealed top-first, or a pane growing sideways), use the proxy counter-scale variant below instead.
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<!-- inside a standard scene clip (hyperframes-core) -->
|
||||
<div class="stack">
|
||||
<div class="expander">
|
||||
<div class="expander-head">{headerLabel}</div>
|
||||
<div class="expand-mask" id="expand-mask" data-layout-allow-overflow>
|
||||
<div class="expand-sheet" id="expand-sheet">
|
||||
<div class="expand-row">{rowA}</div>
|
||||
<div class="expand-row">{rowB}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- EVERYTHING that must be pushed lives in this one wrapper -->
|
||||
<div class="below" id="below">{followingContent}</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
```css
|
||||
/* Layout is the EXPANDED end state — no collapsed geometry exists in CSS. */
|
||||
.expander-head {
|
||||
position: relative;
|
||||
z-index: 2; /* the sheet slides out from UNDER the header */
|
||||
}
|
||||
.expand-mask {
|
||||
height: BODY_H; /* authored final height — NEVER tweened */
|
||||
overflow: hidden;
|
||||
}
|
||||
.expand-sheet {
|
||||
height: BODY_H;
|
||||
border-radius: 0 0 SHEET_RADIUS SHEET_RADIUS; /* bottom-only — header + sheet read as one grown card */
|
||||
will-change: transform; /* + on .below */
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// BODY_H must equal the mask's CSS height exactly — measure once at build.
|
||||
// (Montage caveat: per the contract, in a multi-scene master use an authored
|
||||
// CSS-matched constant instead — later clips may not be laid out yet.)
|
||||
const BODY_H = document.querySelector("#expand-mask").offsetHeight;
|
||||
|
||||
// The grow: ONE tween, BOTH sides of the seam.
|
||||
tl.fromTo(
|
||||
["#expand-sheet", "#below"],
|
||||
{ y: -BODY_H },
|
||||
{ y: 0, duration: GROW_DUR, ease: GROW_EASE },
|
||||
GROW_AT,
|
||||
);
|
||||
|
||||
// Garnish: rows already ride the sheet; the fade stagger makes them read as "options arriving".
|
||||
tl.fromTo(
|
||||
".expand-row",
|
||||
{ opacity: 0 },
|
||||
{ opacity: 1, duration: ROW_FADE_DUR, stagger: ROW_STAGGER, ease: "power2.out" },
|
||||
GROW_AT + GROW_DUR * 0.25,
|
||||
);
|
||||
|
||||
// Collapse — same machinery back; faster (closing is a snap decision).
|
||||
tl.fromTo(
|
||||
["#expand-sheet", "#below"],
|
||||
{ y: 0 },
|
||||
{ y: -BODY_H, duration: COLLAPSE_DUR, ease: "power3.in", immediateRender: false },
|
||||
COLLAPSE_AT,
|
||||
);
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Proxy counter-scale — surface stretches in place** (rows revealed top-first holding their screen positions; the "payload card expands from the tool-call line"). Drive mask `scaleY` and the sheet's exact inverse from ONE proxy — two independent tweens are wrong: eased midpoints of `s` and `1/s` are not inverses and the content squashes mid-grow. Net content scale is `s × 1/s = 1` every frame; seek-safe because everything derives from the one interpolated proxy.
|
||||
|
||||
```js
|
||||
const grow = { h: COLLAPSED_H }; // 0 for fully collapsed
|
||||
tl.fromTo(
|
||||
grow,
|
||||
{ h: COLLAPSED_H },
|
||||
{
|
||||
h: BODY_H,
|
||||
duration: GROW_DUR,
|
||||
ease: GROW_EASE,
|
||||
onUpdate: () => {
|
||||
const s = Math.max(grow.h / BODY_H, 0.0001); // clamp: no divide-by-zero
|
||||
gsap.set("#expand-mask", { scaleY: s, transformOrigin: "50% 0%" });
|
||||
gsap.set("#expand-sheet", { scaleY: 1 / s, transformOrigin: "50% 0%" });
|
||||
gsap.set("#below", { y: grow.h - BODY_H });
|
||||
},
|
||||
},
|
||||
GROW_AT,
|
||||
);
|
||||
```
|
||||
|
||||
- **One-axis pane expand (X)**: same machinery rotated 90° — pin the left edge, sheet from `x: -PANE_W` (or proxy `scaleX` + counter-scale, origin `0% 50%`). Decide the neighbor's fate explicitly: **overlap** (pane paints over it, no neighbor tween) or **push** (neighbor rides the same tween). Never both.
|
||||
- **Typed-wrap growth** — the composer card gets taller as typed text wraps. Quantize: one short step per wrap boundary, each moving the pair by one `LINE_H`; wrap times come from the deterministic typing schedule ([discrete-text-sequence.md](discrete-text-sequence.md)), never measured at render time. Two battle-tested traps:
|
||||
- **Composer cards have no pinned header** — a composer grows from its TOP edge (the send-button footer stays put), so a plain y-step clips the card's top out of the mask. Combine the proxy counter-scale with the wrap quantization (step the proxy by `LINE_H` at each wrap time) and split the surface into a **sheet** (carries the top radius) + **footer** (carries the bottom radius) so the growth seam stays invisible.
|
||||
- **Wrap TIME vs wrap POSITION are two different authorities** — the typing schedule decides _when_ a wrap fires, the browser's line-breaking decides _where_ text actually wraps, and with proportional fonts they silently disagree. Author an explicit `\n` in the typed string (with `white-space: pre-wrap`) at the chosen split point so both derive from the same authored fact.
|
||||
- **Springy open** (rare, explicitly-playful): `back.out(1.2)` — the edge overshoots a few px; the pushed content bounces with the panel (correct — they're in contact). Default stays `power3.out`.
|
||||
- **Row grows a sub-task stack**: the row is the pinned header, the stack is the sheet, every later row lives in `#below`; chain several scopes for progressive disclosure.
|
||||
- **FLIP hand-off**: if the container also TRAVELS to a new layout slot while resizing (prompt promoted to heading, card docking into a sidebar), that's a FLIP problem — `/hyperframes-keyframes` (FLIP recipes). This rule stays the in-place one-axis specialist.
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| ------------------------ | --------------------------- | --------------------------------------------------------------------- |
|
||||
| BODY_H | measured / authored | drift from the CSS height = visible gap or overlap at full open |
|
||||
| GROW_AT | trigger beat + 0–0.1s | growth needs a cause (click / wrap / status beat) or it reads haunted |
|
||||
| GROW_DUR | 0.35–0.6s | below ~0.3s the pushed content appears to teleport |
|
||||
| GROW_EASE | `power3.out` default | `back.out(1.1–1.3)` only for the playful register |
|
||||
| ROW_STAGGER / \_FADE_DUR | 0.04–0.08s / 0.2–0.3s | start rows ~25% into the grow so none flash inside a closed panel |
|
||||
| COLLAPSE_DUR | 0.2–0.35s, `power3.in` | faster than open |
|
||||
| STEP_DUR / LINE_H | 0.12–0.2s / CSS line-height | typed-wrap variant; WRAP_TIMES from the typing script |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **NEVER tween `width` / `height` / `top` / `left` / `margin` / `padding`** — the mask's height is a CSS constant; only its children transform. Tweening the mask IS the forbidden move this rule replaces.
|
||||
- **`data-layout-allow-overflow` on the mask** — the collapsed phase parks the sheet outside the mask's box by construction, which trips the `hyperframes check` layout gate (`container_overflow`). The flag is the sanctioned waiver: this overflow is the technique working as designed, not a bug.
|
||||
- **Sheet + below share one tween (or one proxy)** — matched-but-separate tweens on the two sides of the contact edge are the classic seam bug.
|
||||
- **Everything downstream rides `#below`** — content outside the wrapper is overlapped at t=0 and orphaned during the grow.
|
||||
- **`overflow: hidden` on the mask** — without it the tucked sheet is visible above the header at t=0.
|
||||
- **Counter-scale needs a proxy**, clamped `s ≥ 0.0001` (a fully-collapsed body divides by zero).
|
||||
- **Deterministic sizes** — `BODY_H`, `LINE_H`, `WRAP_TIMES` are build-time constants or one-time measurements, never per-frame layout reads.
|
||||
|
||||
## See also
|
||||
|
||||
`cursor-click-ripple` (the igniting click) · `spring-pop-entrance` (richer per-row arrivals) · `discrete-text-sequence` (the typing that drives stepped growth) · `scale-swap-transition` (the grown menu's exit) · `/hyperframes-keyframes` FLIP (grow + travel).
|
||||
@@ -0,0 +1,128 @@
|
||||
---
|
||||
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 envelope over per-word `{ start, end }` timestamps. In a real ASR pipeline the timings come from a word-level transcript (`hyperframes transcribe` — same shape); for promo video, hand-author them to control emphasis pacing. The envelope never falls to zero after a word — it decays to a rest level, leaving a breadcrumb of recent emphasis.
|
||||
|
||||
## How It Works
|
||||
|
||||
A single linear driver tween (`ease: "none"` — any other ease distorts the per-word envelope; do not change) sweeps scene time; its `onUpdate` loops over ALL words computing each one's envelope: 0 before `start`, linear attack to 1 over `ATTACK_DUR`, sustain at 1 until `end`, decay to `REST_LEVEL` over `RELEASE`, then hold at rest. The envelope drives `text-shadow` blur and `scale` — one driver for the whole phrase, never one tween per word (60+ words would bloat the timeline).
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<!-- inside a standard scene clip (hyperframes-core) -->
|
||||
<div class="phrase">
|
||||
<span class="word" data-word="{w1Key}">{w1}</span>
|
||||
<span class="word" data-word="{w2Key}">{w2}</span>
|
||||
<!-- … the final word may be the brand, with the .brand modifier -->
|
||||
<span class="word brand" data-word="{brandKey}">{brandWord}</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
```css
|
||||
.phrase {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
color: {restColor};
|
||||
}
|
||||
.word {
|
||||
display: inline-block; /* required for transform on <span> */
|
||||
transform-origin: 50% 50%;
|
||||
text-shadow: 0 0 0 {glowColorTransparent};
|
||||
}
|
||||
.word.brand {
|
||||
color: {brandAccentColor};
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// Per-word spoken windows — one entry per span; brand word 1.5-2× a normal word's window.
|
||||
const TIMINGS = {
|
||||
// {w1Key}: { start: …, end: … }, — seconds, local to the scene
|
||||
};
|
||||
|
||||
function envelope(time, start, end) {
|
||||
if (time < start) return 0;
|
||||
if (time < end) return Math.min((time - start) / ATTACK_DUR, 1);
|
||||
const releaseEnd = end + RELEASE;
|
||||
if (time < releaseEnd) return 1 - ((time - end) / RELEASE) * (1 - REST_LEVEL);
|
||||
return REST_LEVEL;
|
||||
}
|
||||
|
||||
const words = document.querySelectorAll(".word");
|
||||
const driver = { t: 0 };
|
||||
tl.to(
|
||||
driver,
|
||||
{
|
||||
t: SCENE_DURATION,
|
||||
duration: SCENE_DURATION,
|
||||
ease: "none", // linear — t maps 1:1 to scene time
|
||||
onUpdate: () => {
|
||||
words.forEach((el) => {
|
||||
const timing = TIMINGS[el.dataset.word];
|
||||
if (!timing) return;
|
||||
const env = envelope(driver.t, timing.start, timing.end);
|
||||
el.style.textShadow = `0 0 ${MAX_BLUR * env}px ${glowColorRgba(env)}`;
|
||||
el.style.transform = `scale(${1 + MAX_SCALE_BOOST * env})`;
|
||||
});
|
||||
},
|
||||
},
|
||||
0,
|
||||
);
|
||||
```
|
||||
|
||||
`glowColorRgba(env)` returns the glow color with `env`-modulated alpha.
|
||||
|
||||
## Variations
|
||||
|
||||
- **Karaoke style (RECOMMENDED for video narration)** — the default amplitudes read too subtle in video: inactive words still dominate. Render inactive words DIM and lerp the active word toward bright + larger; at any moment 1–2 words are bright (spoken + lingering rest) and the rest is dim. Use for short phrases (5–10 words) where one word at a time should POP; keep the subtle default for long dense text. Pushes MAX_BLUR, MAX_SCALE_BOOST, and REST↔ACTIVE contrast; everything else identical:
|
||||
|
||||
```js
|
||||
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"));
|
||||
```
|
||||
|
||||
- **Multi-octave glow** — multiply the sustain by `1 + sin(driver.t × PULSE_HZ) × PULSE_AMPLITUDE` so high-emphasis words breathe at peak.
|
||||
- **Color shift on the peak** — same channel-lerp from `restColor` → `peakColor` as `env` rises (non-karaoke form).
|
||||
- **3D pop-out** — add `translateZ(env × MAX_POP_Z)` so the spoken word leans toward camera; requires `perspective` on the parent.
|
||||
- **From real ASR transcripts** — convert `{ word, start_ms, end_ms }` entries to seconds and feed in identically.
|
||||
|
||||
## Values
|
||||
|
||||
| token | default style | karaoke style | notes |
|
||||
| --------------- | -------------------- | ------------- | ---------------------------------------------------------- |
|
||||
| ATTACK_DUR | 0.1–0.25s | same | must be < the shortest word's window or it never reaches 1 |
|
||||
| RELEASE | 0.2–0.5s | same | decay to rest |
|
||||
| REST_LEVEL | 0.15–0.4 | 0.05–0.2 | > 0 (breadcrumb), < 1 |
|
||||
| MAX_BLUR | 15–25px | 30–45px | bigger = "shouting" |
|
||||
| MAX_SCALE_BOOST | 0.03–0.10 | 0.15–0.25 | additive at peak (0.08 ⇒ scale 1.08) |
|
||||
| PULSE_HZ / AMP | 4–10 rad/s / 0.1–0.3 | — | multi-octave variation |
|
||||
| MAX_POP_Z | 20–60px | — | 3D variation |
|
||||
| SCENE_DURATION | = `data-duration` | same | driver must end in sync with the scene's seek window |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Timings monotonic, non-overlapping** — every entry's `end` < the next entry's `start`; overlapping windows make the envelope ambiguous.
|
||||
- **Brand word window 1.5–2× a normal word** — the brand is the headline; let it sustain.
|
||||
- **Driver ease stays `"none"`** — any other ease warps every word's envelope timing.
|
||||
- **`text-shadow`, not `box-shadow`** — the glow must hug the GLYPH (speaking emphasis), not the inline-block rectangle.
|
||||
- **One driver looping all words** — never one tween per word.
|
||||
- **Commit to a style** — values between the default and karaoke columns yield awkward "half-loud" emphasis.
|
||||
- **Climax dwell ≥1s** after the final word's emphasis — the last word IS the headline beat.
|
||||
|
||||
## See also
|
||||
|
||||
`3d-text-depth-layers` (depth on the active word at peak) · `sine-wave-loop` (idle breathe between emphasis moments) · `context-sensitive-cursor` (typewriter matching the ASR cadence) · `/media-use` for `hyperframes transcribe` and caption rendering.
|
||||
@@ -0,0 +1,145 @@
|
||||
---
|
||||
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 on an elliptical ring around a central hub (logo / counter), with SVG dashed lines drawing outward from the hub to each avatar — "community" / social proof. Distinct from [orbit-3d-entry.md](orbit-3d-entry.md) (continuous orbit): this settles into a static composed formation.
|
||||
|
||||
## How It Works
|
||||
|
||||
Three layers: SVG lines (z-index 1, behind), avatars (z-index 2), hub (z-index 5 — lines terminate AT its edge, never pass through). Avatar positions and lines are built once at setup from ONE shared center; the timeline then runs hub fade → avatar cascade → outward line draw → breathing dwell. Drawing FROM the center is the narrative: "the hub connects to its community."
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<!-- inside a standard scene clip (hyperframes-core) -->
|
||||
<svg class="lines" viewBox="0 0 1920 1080"><!-- lines injected --></svg>
|
||||
<div class="hub-wrap">
|
||||
<div class="hub">{counterValue} {counterLabel}</div>
|
||||
<!-- avatars injected -->
|
||||
</div>
|
||||
```
|
||||
|
||||
```css
|
||||
.lines {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.hub-wrap {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.hub {
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
}
|
||||
.avatar {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
transform: translate(-50%, -50%); /* centers on the (left, top) the script sets */
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// CENTER_X/Y must equal the hub's RENDERED center exactly — every avatar
|
||||
// position and line endpoint derives from it. For a place-items:center hub on
|
||||
// a 1920×1080 canvas: (W/2, H × CENTER_Y_FACTOR).
|
||||
const C = { x: CENTER_X, y: CENTER_Y };
|
||||
const wrap = document.querySelector(".hub-wrap");
|
||||
const svg = document.querySelector(".lines");
|
||||
|
||||
for (let i = 0; i < AVATAR_COUNT; i++) {
|
||||
const a = (i / AVATAR_COUNT) * Math.PI * 2 - Math.PI / 2; // start at top
|
||||
const x = C.x + Math.cos(a) * RADIUS_X;
|
||||
const y = C.y + Math.sin(a) * RADIUS_Y;
|
||||
|
||||
const av = document.createElement("div");
|
||||
av.className = "avatar"; // assign image / glyph from authoring data
|
||||
av.style.left = `${x}px`;
|
||||
av.style.top = `${y}px`;
|
||||
wrap.appendChild(av);
|
||||
|
||||
const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
|
||||
const attrs = {
|
||||
x1: C.x,
|
||||
y1: C.y,
|
||||
x2: x,
|
||||
y2: y,
|
||||
stroke: "{lineColor}",
|
||||
"stroke-dasharray": "6 8",
|
||||
};
|
||||
Object.entries(attrs).forEach(([k, v]) => line.setAttribute(k, String(v)));
|
||||
const len = Math.hypot(x - C.x, y - C.y); // straight line — Math.hypot, not getTotalLength()
|
||||
line.style.strokeDashoffset = String(len);
|
||||
svg.appendChild(line);
|
||||
}
|
||||
|
||||
tl.from(".hub", { opacity: 0, scale: 0.8, duration: HUB_DUR, ease: `back.out(${HUB_BOUNCE})` }, 0);
|
||||
|
||||
const avatars = document.querySelectorAll(".avatar");
|
||||
avatars.forEach((av, i) => {
|
||||
tl.from(
|
||||
av,
|
||||
{ opacity: 0, scale: 0, duration: AVATAR_DUR, ease: `back.out(${AVATAR_BOUNCE})` },
|
||||
AVATAR_AT + i * AVATAR_STAGGER,
|
||||
);
|
||||
});
|
||||
svg.querySelectorAll("line").forEach((line, i) => {
|
||||
tl.to(
|
||||
line,
|
||||
{ strokeDashoffset: 0, duration: LINE_DUR, ease: "power2.out" },
|
||||
LINES_AT + i * LINE_STAGGER,
|
||||
);
|
||||
});
|
||||
|
||||
// Climax dwell — out-of-phase breathing holds the eye on the formed network:
|
||||
// one phase proxy (0 → 2π·BREATH_CYCLES, ease "none"); onUpdate scales avatar i by
|
||||
// 1 + sin(p + (i/n)·2π) · BREATH_AMP — sine-wave-loop's multiplicative onUpdate form.
|
||||
// Keep the -50% centering in the same transform write.
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Size variety**: vary avatar sizes by a small index-keyed array so the ring doesn't read rigidly repetitive.
|
||||
- **Solid lines**: drop the dash + draw; lines fade in via opacity — more corporate, less networky.
|
||||
- **Multi-orbit**: inner ring (fewer, larger) connected to the hub; outer ring is an unconnected "halo."
|
||||
- **Glyph avatars**: flags / emoji / icons instead of faces — reads "global community" or role spread.
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| -------------- | ---------------------------- | ---------------------------------------------------------------- |
|
||||
| AVATAR_COUNT | 8–12 | fewer feels sparse; more clutters the ellipse |
|
||||
| RADIUS_X / \_Y | ~20–30% W / ~18–25% H | ratio X/Y 1.5–3.0 reads as perspective; 1 (circle) reads flat |
|
||||
| avatar size | 80–120px @1920 | ring must fit 10+ without overlap |
|
||||
| HUB_DUR | 0.4–0.6s | HUB_BOUNCE 1.4–1.8 |
|
||||
| AVATAR_AT | ≥ 0.6 × HUB_DUR | hub established before satellites arrive |
|
||||
| AVATAR_DUR | 0.4–0.7s | AVATAR_BOUNCE 1.4–1.8, slightly firmer than hub |
|
||||
| AVATAR_STAGGER | 0.06–0.10s | cascade reads "joining"; simultaneous reads "already there" |
|
||||
| LINES_AT | overlaps last avatar settle | start ~0.1–0.2s before it — draw reads as consequence of landing |
|
||||
| LINE_DUR | 0.4–0.7s | LINE_STAGGER 0.02–0.05s = a wave outward |
|
||||
| BREATH_CYCLES | 1.0–2.0 over the remaining s | under 1 = single sigh; over 2 = anxious. BREATH_AMP 0.02–0.06 |
|
||||
|
||||
Tokens: dark `{bgColor}` so the cloud reads as a constellation; translucent accent `{lineColor}`; soft border + glow keeps avatars legible on dark.
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **CENTER_X/Y must match the hub's actual rendered center** — when composed with another scene (e.g. a recentered logo), bake them from the same source as the hub's final position, or lines visibly miss the hub.
|
||||
- **Hub z-index above lines** — lines terminate at the hub edge, never cross it.
|
||||
- **Lines draw outward** (dashoffset len → 0), starting after avatars are mostly settled.
|
||||
- **`RADIUS_X > RADIUS_Y`** — a horizontal ellipse reads as perspective; a circle reads flat.
|
||||
- **Climax dwell ≥ 1s** after lines complete so the formed network is readable.
|
||||
- Straight lines: `Math.hypot` for length — `getTotalLength()` not needed.
|
||||
|
||||
## See also
|
||||
|
||||
`counting-dynamic-scale` (the hub IS a growing counter) · `sine-wave-loop` (the breathing form) · `orbit-3d-entry` (the continuously-orbiting cousin).
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
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 (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; the camera doesn't move. Anchors the viewer's eye before tracking begins.
|
||||
- **Phase 2 (Tracking)** — activates when the focal point (cursor, highlight, last typed glyph) exceeds a target screen position (`CURSOR_TARGET_FRACTION × viewportWidth` from the left). The world 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:
|
||||
|
||||
```
|
||||
finalWorldX = Math.min(INITIAL_OFFSET, trackingOffset)
|
||||
```
|
||||
|
||||
`INITIAL_OFFSET` is the static-phase value; `trackingOffset` is whatever shift keeps the focal point at the target screen X. While the focal point hasn't grown past the target, `trackingOffset` is a less-negative number and `Math.min` returns the static value; once the focal point would cross the target, `trackingOffset` overtakes and tracking takes over. Do NOT replace this with a hard `if (typingProgress > threshold)` branch — the camera will visibly jump.
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<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>
|
||||
```
|
||||
|
||||
```css
|
||||
.viewport {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden; /* clip the world's left edge as it pans off-screen */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding-left: VIEWPORT_PAD_LEFT; /* Phase-1 anchor X — must match the JS constant */
|
||||
}
|
||||
.world {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap; /* text must stay on one line for the camera math */
|
||||
}
|
||||
.search-bar .text {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
.search-bar .cursor {
|
||||
display: inline-block; /* inline sibling of the text, NOT absolutely positioned —
|
||||
absolute positioning misaligns with the camera math */
|
||||
width: CURSOR_WIDTH;
|
||||
margin-left: CURSOR_GAP;
|
||||
background: {accentColor};
|
||||
height: CURSOR_HEIGHT_EM;
|
||||
vertical-align: bottom;
|
||||
/* no CSS blink animation — CSS clocks don't sync to seek; blink is a GSAP tween below */
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// Pre-measure the target text width to compute tracking distance.
|
||||
// Measure SYNCHRONOUSLY — no fonts.ready gate (see Critical Constraints).
|
||||
const textEl = document.getElementById("reveal-text");
|
||||
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. maxWidth tween
|
||||
// (width/left/top tweens are forbidden); ease "none" = linear typing rate.
|
||||
tl.fromTo(
|
||||
".search-bar .text",
|
||||
{ maxWidth: 0 },
|
||||
{ maxWidth: fullWidth, duration: REVEAL_DUR, ease: "none" },
|
||||
REVEAL_START,
|
||||
);
|
||||
|
||||
// Phase 2 — camera tracks. Start BEFORE full reveal so the handoff feels
|
||||
// continuous (Math.min form above makes it mathematically continuous).
|
||||
tl.to(".world", { x: -trackingDelta, duration: TRACK_DUR, ease: "power2.inOut" }, TRACK_START);
|
||||
|
||||
// Cursor blink — finite GSAP yoyo (never CSS @keyframes; CSS animation clocks
|
||||
// aren't synced to HF's seek and flicker non-deterministically).
|
||||
const blinkRepeats = Math.ceil(SCENE_DURATION / BLINK_HALF_PERIOD) - 1;
|
||||
tl.to(
|
||||
".search-bar .cursor",
|
||||
{ opacity: 0, duration: BLINK_HALF_PERIOD, ease: "steps(1)", yoyo: true, repeat: blinkRepeats },
|
||||
0,
|
||||
);
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Centered → center-tracked**: `.viewport { justify-content: center; padding: 0; }`, `CURSOR_TARGET_FRACTION = 0.5` — tracks once the focal point crosses the midline.
|
||||
- **Left-aligned → right-tracked**: as written; 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` driving the cursor screen X — required when the typed text is consumed elsewhere in the scene (e.g. by a parent strip's camera offset).
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| ---------------------- | --------------------------- | ------------------------------------------------------------------------------- |
|
||||
| VIEWPORT_PAD_LEFT | 0 → ~10% of viewport width | must match the CSS `padding-left` or the camera math drifts |
|
||||
| VIEWPORT_WIDTH | = the root's `data-width` | never tweened |
|
||||
| CURSOR_TARGET_FRACTION | 0.5–0.75 | lower = less revealed text in frame; higher delays tracking |
|
||||
| CURSOR_WIDTH / GAP | 4–10 px / a few px | gap ≤ cursor width or it visually detaches |
|
||||
| CURSOR_HEIGHT_EM | 0.85–1.0 em | matches the typed glyph height |
|
||||
| REVEAL_DUR | chars × 0.05–0.15s | ease `"none"` — any easing distorts the per-keystroke cadence |
|
||||
| TRACK_START | < REVEAL_START + REVEAL_DUR | overlap the reveal so the handoff feels continuous |
|
||||
| TRACK_DUR | 0.8–2.0s | `power2.inOut`/`power3.inOut`; `back.out` reads as UI bounce, not camera |
|
||||
| BLINK_HALF_PERIOD | 0.2–0.4s | `steps(1)` hard on/off; repeats derived from SCENE_DURATION (= `data-duration`) |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Build the timeline SYNCHRONOUSLY — no `fonts.ready` gate.** HF renders frames in parallel workers, each a fresh browser. A `document.fonts.ready.then(...)` wrapper means some workers seek frames BEFORE the Promise resolves and find no timeline → those frames render at CSS initial state (`max-width: 0` ⇒ empty text) while others render correctly → visible flicker. Register the timeline at script-parse time: the camera math tolerates a few percent width error from fallback-font measurement; worker-race flicker is unacceptable. If precise post-font measurement matters, re-measure inside the tween's `onUpdate` (still deterministic per-frame), or set `font-display: block` on the @font-face.
|
||||
- **Measure with `getBoundingClientRect()` / `scrollWidth` / probe nodes**, never character count × font-size — proportional fonts have variable glyph widths.
|
||||
- **Continuous math at the phase boundary** — the `Math.min(INITIAL_OFFSET, trackingOffset)` form, never a hard threshold branch.
|
||||
- **`white-space: nowrap` on the world** and pre-allocated width (tween `maxWidth` to the full target width) — prevents layout shift mid-tween.
|
||||
- **Cursor is an inline sibling of the text**, and blinks via a finite GSAP yoyo — never CSS `@keyframes … infinite`.
|
||||
- **`overflow: hidden` on `.viewport`** — clips the world as it pans.
|
||||
|
||||
## See also
|
||||
|
||||
[context-sensitive-cursor.md](context-sensitive-cursor.md) (cursor color per text segment) · [discrete-text-sequence.md](discrete-text-sequence.md) (non-linear text reveals under this camera).
|
||||
@@ -0,0 +1,134 @@
|
||||
---
|
||||
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 free-floating container morphs apparent size, corner radius, and surface treatment between two shots — the morph itself IS the transition; the viewer's eye tracks the persistent container. Distinct from [anchored-layout-expand.md](anchored-layout-expand.md) (an edge-pinned live layout participant that grows along one axis and reflows neighbors — here nothing is pushed) and [theme-crossfade-morph.md](theme-crossfade-morph.md) (a whole-theme reskin under a fixed anchor — here a single container changes shape).
|
||||
|
||||
## How It Works
|
||||
|
||||
Since `width`/`height` tweens are forbidden, **substitute uniform `scale` for apparent size**; the remaining morph channels are **paint-only**: `borderRadius`, `background`, `boxShadow`. All channels ride ONE tween (one ease, one duration) so the shape morphs in lockstep. Content choreography: old content fades out during the first ~40% of the morph, new content fades in during the last ~40% — the shape-only gap between is the natural "blink." Optionally the morph card itself fades at the very end, revealing the real next-shot element rendered behind it.
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<!-- inside a standard scene clip (hyperframes-core) -->
|
||||
<!-- DOM order = stacking: the anchor renders BEFORE the card, so the card is on top -->
|
||||
<div class="next-shot-anchor"><img src="{nextShotAnchor}" alt="anchor" /></div>
|
||||
<div class="morph-card">
|
||||
<div class="content-old">{shotOneContent}</div>
|
||||
<div class="content-new">{shotTwoContent}</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
```css
|
||||
.morph-card {
|
||||
width: SHOT_ONE_W;
|
||||
height: SHOT_ONE_H; /* shot-1 geometry; the morph is scale, never width/height */
|
||||
border-radius: SHOT_ONE_RADIUS;
|
||||
background: {surfaceShotOne};
|
||||
overflow: hidden; /* content must clip during the shape change */
|
||||
display: grid;
|
||||
place-items: center;
|
||||
will-change: transform;
|
||||
}
|
||||
.content-old,
|
||||
.content-new {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.content-new {
|
||||
opacity: 0; /* author its inner sizes at apparent-size ÷ END_SCALE — it scales with the card */
|
||||
}
|
||||
.next-shot-anchor {
|
||||
position: absolute;
|
||||
opacity: 0; /* fades in as the morph card fades out */
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
const END_SCALE = SHOT_TWO_W / SHOT_ONE_W; // uniform — keep the two shots aspect-matched
|
||||
|
||||
// Hold shot 1 for HOLD_BEAT first — an instant morph reads as glitchy.
|
||||
|
||||
// One tween, all channels: uniform scale + paint-only properties.
|
||||
tl.to(
|
||||
".morph-card",
|
||||
{
|
||||
scale: END_SCALE,
|
||||
borderRadius: SHOT_TWO_RADIUS / END_SCALE, // borderRadius is pre-scale — divide to land the APPARENT radius
|
||||
background: "{surfaceShotTwo}",
|
||||
boxShadow: "{shadowShotTwo}",
|
||||
duration: MORPH_DUR,
|
||||
ease: "power2.inOut",
|
||||
},
|
||||
MORPH_START,
|
||||
);
|
||||
|
||||
tl.to(
|
||||
".content-old",
|
||||
{ opacity: 0, duration: MORPH_DUR * OLD_FADE_FRAC, ease: "power1.in" },
|
||||
MORPH_START,
|
||||
);
|
||||
tl.to(
|
||||
".content-new",
|
||||
{ opacity: 1, duration: MORPH_DUR * NEW_FADE_FRAC, ease: "power1.out" },
|
||||
MORPH_START + MORPH_DUR * (1 - NEW_FADE_FRAC),
|
||||
);
|
||||
|
||||
// Optional handoff — card fades out over the pixel-identical real anchor.
|
||||
tl.to(
|
||||
".morph-card",
|
||||
{ opacity: 0, duration: MORPH_DUR * FINAL_FADE_FRAC, ease: "power1.in", immediateRender: false },
|
||||
MORPH_START + MORPH_DUR * (1 - FINAL_FADE_FRAC),
|
||||
);
|
||||
tl.to(
|
||||
".next-shot-anchor",
|
||||
{ opacity: 1, duration: MORPH_DUR * FINAL_FADE_FRAC, ease: "power1.out" },
|
||||
MORPH_START + MORPH_DUR * (1 - FINAL_FADE_FRAC),
|
||||
);
|
||||
```
|
||||
|
||||
## Morph channels
|
||||
|
||||
| channel | how |
|
||||
| -------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| apparent size | uniform `scale` — the substitution for the forbidden `width`/`height` tween; aspect preserved |
|
||||
| `borderRadius` | paint-only; pre-scale units — tween to `APPARENT_RADIUS / END_SCALE`, ≤ half the smaller side |
|
||||
| `background` | paint-only; gradients interpolate only with equal stop counts (solid→solid: `backgroundColor`) |
|
||||
| `boxShadow` | paint-only; base shadow → accent glow shifts emphasis |
|
||||
|
||||
## Variations
|
||||
|
||||
- **Landing on a non-centered target** (dock icon, sidebar slot): add `x`/`y` to the same tween, computed as the FLIP-style delta between the card's and the target's rects — `getBoundingClientRect()` both at build time (single-scene only, per the contract) and tween the difference. Don't hand-compute from CSS values: paddings, borders, and parent transforms compound, and center-vs-edge arithmetic is the classic off-by-half bug.
|
||||
- **Aspect change between shots**: uniform scale preserves aspect — morph to the nearest uniform fit and let the crossfade/handoff absorb the small delta, or drop the handoff and hold the card's final state.
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| ----------------- | ------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| HOLD_BEAT | 0.6–1.5s | ≥ shot 1's entry settle; the viewer must register shot 1 first |
|
||||
| MORPH_DUR | 0.6–1.2s | < 0.5s can't fit both content fades |
|
||||
| END_SCALE | SHOT_TWO_W / SHOT_ONE_W | icon-sized handoffs typically land at 80–400px apparent width |
|
||||
| SHOT_TWO_RADIUS | ≤ min(W, H)/2 apparent | half the smaller side = perfect circle; beyond is clamped |
|
||||
| OLD/NEW_FADE_FRAC | 0.3–0.5 each, sum ≤ 1 | the gap between is the shape-only "blink" |
|
||||
| FINAL_FADE_FRAC | 0 (no handoff) or 0.1–0.2 | only when a pixel-identical anchor exists |
|
||||
| ease | `power2.inOut` canonical | `power3`/`expo.inOut` OK; never `back`/`elastic` — overshoot fights the shape change |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **❗ Uniform-scale substitution** — never tween `width`/`height`; `scale` + the paint-only channels (`borderRadius`, `background`, `boxShadow`) are the ONLY morph properties.
|
||||
- **❗ Handoff anchor must be pixel-identical to the card's final state** — same apparent size, radius, background, shadow, inner icon dimensions. Any delta = a visible pop during the crossfade. Can't match exactly? Drop the handoff and hold the morph card.
|
||||
- **❗ Stacking by DOM order, never a z-index snap mid-fade** — render the anchor before the card; a `tl.set({ zIndex })` during an active opacity tween flips stacking before the fade finishes and flickers.
|
||||
- **`overflow: hidden`** on the card — content must clip as the radius changes.
|
||||
- **Hold a beat before morphing**; same ease family for shape and crossfade (mixed eases read unsynchronized).
|
||||
|
||||
## See also
|
||||
|
||||
`anchored-layout-expand` (edge-pinned one-axis growth with reflow) · `theme-crossfade-morph` (whole-theme reskin under a fixed anchor) · `scale-swap-transition` (content swap without shape change) · `sine-wave-loop` (a breath on the final state).
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
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 one shared center point and radiate outward to their final positions — the entry beat itself, or motion driven by another animation's progress (a counting number, a beat). Flat 2D cousin of [depth-scatter-assemble.md](depth-scatter-assemble.md) (per-element 3D cloud): here every element shares the SAME origin.
|
||||
|
||||
## How It Works
|
||||
|
||||
Each element carries its final offset as `data-target-x/y`. Its position lerps between center and target: `x = targetX × progress`. Self-centering is baked as `xPercent/yPercent: -50` so the tweened `x`/`y` are pure offsets from the stage center. Standalone burst = per-item staggered `fromTo`; driven burst = one shared proxy (see Variations).
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<!-- inside a standard scene clip (hyperframes-core) -->
|
||||
<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="0" data-target-y="360">{itemC}</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
```css
|
||||
.burst-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.burst-item {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%; /* GSAP xPercent/yPercent -50 bakes the centering; x/y tween the offset */
|
||||
will-change: transform;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
document.querySelectorAll(".burst-item").forEach((el, i) => {
|
||||
tl.fromTo(
|
||||
el,
|
||||
{ xPercent: -50, yPercent: -50, x: 0, y: 0, scale: 0.6, opacity: 0 },
|
||||
{
|
||||
x: Number(el.dataset.targetX),
|
||||
y: Number(el.dataset.targetY),
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
duration: EXPAND_DUR,
|
||||
ease: EXPAND_EASE,
|
||||
},
|
||||
ENTRY_AT + i * STAGGER,
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Synced to a driver (chord)**: when the burst shadows a counter / beat, drop the stagger and drive all items from ONE 0→1 proxy tween with the driver's exact duration AND ease; `onUpdate` writes `translate(-50%,-50%) translate(targetX*p, targetY*p)` per item — the two read as one beat.
|
||||
- **Partially-spread start**: with 6+ items the full cluster piles up — start from `{ x: targetX * START_PROGRESS, ... }`.
|
||||
- **Idle micro-float**: hand off to [sine-wave-loop.md](sine-wave-loop.md) after landing instead of freezing.
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| -------------- | -------------------- | ---------------------------------------------------------------- |
|
||||
| ITEM_COUNT | 3–8 | > 8 = visual chaos mid-expansion; low counts want wider spread |
|
||||
| EXPAND_DUR | 1.0–1.8s | must equal the driver's duration in the synced variant |
|
||||
| EXPAND_EASE | `power3.out` default | `power2.out` gentler, `expo.out` dramatic stop; NEVER `in` eases |
|
||||
| STAGGER | 0.04–0.08s | tighter = chord; looser = lazy arpeggio |
|
||||
| ENTRY_AT | 0–0.5s | a beat of compositional quiet before the burst |
|
||||
| START_PROGRESS | 0–0.5 | 0 = dramatic full cluster; ~0.3 avoids the pile-up |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Tween `x`/`y` over the baked `xPercent/yPercent: -50`** — mutating `left`/`top` fights the centering and causes pixel jitter.
|
||||
- **Out-easing only** — `in` easings read as items being sucked back mid-air.
|
||||
- **No other absolute-positioned siblings inside `.burst-wrap`** — they'd steal the centered baseline.
|
||||
- **❗ The burst IS the beat** — don't park a "real headline" label below it (the eye snaps to the label and ignores the burst). If a label is needed, reveal it post-burst in the same stack.
|
||||
- Synced variant: identical duration + ease as the driver, or the chord falls apart.
|
||||
|
||||
## See also
|
||||
|
||||
`counting-dynamic-scale` (the classic chord driver) · `depth-scatter-assemble` (3D per-element cloud) · `card-morph-anchor` (burst out of a morphed card) · `sine-wave-loop` (post-landing life).
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
name: chart-scrub-readout
|
||||
description: A cursor/playhead scrubs an already-drawn chart — one driver moves a vertical tracking line and marker along a baked data polyline while a date/value tooltip steps through the data array; a second series can activate on cross. Deterministic data, readout writes only on index change.
|
||||
metadata:
|
||||
tags: chart, scrub, readout, tooltip, tracking-line, data, cursor, playhead
|
||||
---
|
||||
|
||||
# Chart Scrub Readout
|
||||
|
||||
The chart is already ON screen — this rule **interrogates** it. A vertical tracking line rides the scrub position, a marker dot follows the series, and a live tooltip reads out `date: value` per position, values flickering past like an odometer. It's the "this data is real — look closer" beat: the scrub proves the chart is an instrument, not a picture.
|
||||
|
||||
Boundary with its neighbors: [stat-bars-and-fills.md](stat-bars-and-fills.md) owns the chart's ARRIVAL; [counting-dynamic-scale.md](counting-dynamic-scale.md) owns a single number swelling in place. This rule assumes the graphic already exists and adds a **read head** moving across it. The three chain naturally: the line draws in (svg-path-draw / stat-bars), this rule scrubs it, and the landing value hands off to a count-up lockup.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Data baked at setup** — a literal `DATA` array of `{ d, v }` points (or a pure index formula). The polyline's `points` attribute is computed ONCE from `DATA` by pure mapping functions: chart and readout share one source of truth. The argument of the shot is "this data is real" — a random walk regenerated per render breaks both determinism and the rhetorical claim.
|
||||
2. **One driver tween** `p: 0 → 1` derives everything in its `onUpdate`: tracking-line x, marker x/y, tooltip position. Every output is a pure function of `p` — any seek lands the identical frame. Parallel tweens that merely share timing drift apart under rounding and read as chart chrome, not a read head.
|
||||
3. **The marker rides the polyline** — its y interpolates between the two neighboring baked points, from the same arrays that built the chart; a separately-keyframed marker inevitably floats off the line.
|
||||
4. **The readout is threshold-stepped** — the nearest data index derives from `p`, and `textContent` is written ONLY when that index changes (last-index guard). Transforms glide per frame (compositor-cheap); text steps per data point — no per-frame DOM text thrash. The guard is an optimization, not state: any seek recomputes the same index and the same text.
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<!-- inside a standard scene clip. Size the SVG so viewBox units === CSS pixels:
|
||||
one coordinate space serves the polyline, tracking line, marker, AND the HTML tooltip. -->
|
||||
<div class="chart-wrap">
|
||||
<!-- position: relative — the tooltip transforms against this box -->
|
||||
<svg class="chart" viewBox="0 0 CHART_W CHART_H" width="CHART_W" height="CHART_H">
|
||||
<polyline id="series-a" class="series" fill="none" />
|
||||
<line id="track-line" y1="0" y2="CHART_H" stroke-dasharray="6 6" />
|
||||
<circle id="marker" r="MARKER_R" />
|
||||
</svg>
|
||||
<div class="tooltip" id="tooltip">
|
||||
<span id="tip-date">{firstDate}</span>
|
||||
<span id="tip-value">{firstValue}</span>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
```css
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
min-width: TIP_MIN_WIDTH; /* fixed — the box must not resize as values change length */
|
||||
}
|
||||
#tip-value {
|
||||
font-variant-numeric: tabular-nums; /* MANDATORY — digits flicker past; widths must not */
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// Data baked at setup — literal values.
|
||||
const DATA = [
|
||||
{ d: "{date1}", v: V1 },
|
||||
// ... N points, chronological ...
|
||||
];
|
||||
|
||||
// Pure mapping functions — geometry derives from DATA once.
|
||||
const PAD = CHART_PAD;
|
||||
const PLOT_W = CHART_W - PAD * 2;
|
||||
const PLOT_H = CHART_H - PAD * 2;
|
||||
const vals = DATA.map((p) => p.v);
|
||||
const V_MIN = Math.min(...vals);
|
||||
const V_MAX = Math.max(...vals);
|
||||
const X = (i) => PAD + (i / (DATA.length - 1)) * PLOT_W;
|
||||
const Y = (v) => PAD + PLOT_H * (1 - (v - V_MIN) / (V_MAX - V_MIN));
|
||||
|
||||
document
|
||||
.getElementById("series-a")
|
||||
.setAttribute("points", DATA.map((p, i) => `${X(i)},${Y(p.v)}`).join(" "));
|
||||
|
||||
const line = document.getElementById("track-line");
|
||||
const marker = document.getElementById("marker");
|
||||
const tooltip = document.getElementById("tooltip");
|
||||
const tipDate = document.getElementById("tip-date");
|
||||
const tipValue = document.getElementById("tip-value");
|
||||
|
||||
// Tooltip pops in as the scrub begins — a small fromTo scale/opacity spring at SCRUB_AT.
|
||||
|
||||
// ONE driver — line, marker, and tooltip are all projections of p.
|
||||
const scrub = { p: 0 };
|
||||
let lastIdx = -1;
|
||||
tl.to(
|
||||
scrub,
|
||||
{
|
||||
p: 1,
|
||||
duration: SCRUB_DUR,
|
||||
ease: SCRUB_EASE,
|
||||
onUpdate: () => {
|
||||
const f = scrub.p * (DATA.length - 1); // fractional index
|
||||
const i = Math.min(DATA.length - 2, Math.floor(f));
|
||||
const t = f - i;
|
||||
const x = X(i) + (X(i + 1) - X(i)) * t;
|
||||
const y = Y(DATA[i].v) + (Y(DATA[i + 1].v) - Y(DATA[i].v)) * t;
|
||||
|
||||
// Transforms glide every frame (cheap, deterministic)
|
||||
line.setAttribute("x1", x);
|
||||
line.setAttribute("x2", x);
|
||||
marker.setAttribute("cx", x);
|
||||
marker.setAttribute("cy", y);
|
||||
tooltip.style.transform = `translate(${x + TIP_DX}px, ${y - TIP_DY}px)`;
|
||||
|
||||
// Text steps only when the nearest data point changes
|
||||
const idx = Math.round(f);
|
||||
if (idx !== lastIdx) {
|
||||
tipDate.textContent = DATA[idx].d;
|
||||
tipValue.textContent = `${DATA[idx].v.toLocaleString()} {unitLabel}`;
|
||||
lastIdx = idx;
|
||||
}
|
||||
},
|
||||
},
|
||||
SCRUB_AT,
|
||||
);
|
||||
// End hold: the driver finishes before the scene does — the landed value reads.
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Peak stop** — the scrub is the wind-up, the landing is the stat: `SCRUB_EASE: "power3.out"` decelerates onto the final/peak point, then pop the emphasis at landing (`fromTo` marker `scale: 1 → PEAK_POP_SCALE` at `SCRUB_AT + SCRUB_DUR`). Pair with a pill tooltip that springs to its final label ([spring-pop-entrance.md](spring-pop-entrance.md)) — the classic "line breaks above the band" climax.
|
||||
- **Second-series activation on cross** — series B sits dimmed; at `SCRUB_AT + SCRUB_DUR * CROSS_P` tween its stroke to the lit color (0.25s, `power2.out`), and in the driver's `onUpdate` read from B's array once `scrub.p ≥ CROSS_P` (still index-guarded). The color flip lands ON the cross — same-frame causality.
|
||||
- **Two-chart glide** — two scrub beats: sweep chart A, glide the cursor/tooltip group across the gutter (a plain `x` tween, no readout — dead travel, not data), then chart B activates with its own driver. One driver per chart.
|
||||
- **Cursor-led scrub** — an oversized cursor is the visible actor: another projection of the SAME driver (positioned from `x` in the same `onUpdate`, tip at the tracking line's head) — never a second tween that merely matches timing. Cursor look and click grammar from [cursor-click-ripple.md](cursor-click-ripple.md).
|
||||
- **Playhead form** — no cursor; the tracking line IS the actor (timeline scrubbers, audio waves, session replays). `ease: "none"` — mechanical playback, not a hand.
|
||||
|
||||
## Values
|
||||
|
||||
| token | range / default | notes |
|
||||
| ----------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| N (data points) | 10–40 | <10 reads as a slideshow; >40 blurs into texture. The flicker is the point — only first and final values must be legible |
|
||||
| SCRUB_DUR | 1.5–3s | shorter = confident sweep; longer = inspection. Leave ≥0.8s of scene after the driver ends so the landed value holds |
|
||||
| SCRUB_EASE | `power1.inOut` default | `"none"` playhead form; `power3.out` peak stop. Never `back.out` — a read head that overshoots and re-reads looks broken |
|
||||
| CROSS_P | 0.55–0.75 | earlier and A never establishes; later and B's readout has no time to live |
|
||||
| TIP_DX / TIP_DY | 16–48px, up-and-right | flip the sign near the chart's right edge so the tooltip never exits the frame |
|
||||
| MARKER_R / stroke width | r 6–12 / 4–8px | the marker must dominate the line it rides |
|
||||
| TIP_MIN_WIDTH | ≥ longest `date: value` state | without it the box breathes as digits change |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **`DATA` is literal at setup**; polyline points derive from it via pure functions — chart and readout share one source of truth.
|
||||
- **Seed at setup** — call the scrub applier once with `p = 0` right after building (à la `3d-camera-flight`'s `applyCamera()`), or a seek to t=0 before the driver runs shows the tracking line/marker at their HTML-default positions.
|
||||
- **Single driver** — one `p` tween; all scrub outputs (line, marker, tooltip, any cursor) computed in its `onUpdate`, each a pure function of `p`.
|
||||
- **Readout writes guarded by index change** — `onUpdate` stays O(1): a few attribute sets, one transform, text only on step.
|
||||
- **SVG `viewBox` units = CSS pixels** (`viewBox="0 0 W H"` with matching `width`/`height`) — one coordinate space must serve the SVG internals and the HTML tooltip's transform.
|
||||
- **`tabular-nums` + fixed `min-width`** on the tooltip value.
|
||||
- **The chart pre-exists** — draw-in belongs to `svg-path-draw` / `stat-bars-and-fills`; sequence it BEFORE the scrub, don't blend them.
|
||||
- **Land the read** — hold the final value ≥0.8s (or hand off to a count-up lockup).
|
||||
|
||||
## See also
|
||||
|
||||
`svg-path-draw` (the series draws in first) · `stat-bars-and-fills` (surrounding dashboard chrome) · `spring-pop-entrance` (peak dot + pill pop at the landing) · `counting-dynamic-scale` (closing stat lockup) · `cursor-click-ripple` / `context-sensitive-cursor` (the cursor-led form's actor) · `control-target-sync` (the sibling WRITE direction — there a control edits a target; here a scrub reads a dataset).
|
||||
@@ -0,0 +1,150 @@
|
||||
---
|
||||
name: chromatic-glitch
|
||||
description: RGB-split / slice glitch that snaps sharp — offset color copies jitter on a deterministic hash of quantized timeline time (never Math.random), or horizontal slices displace and converge; a brief vibration, then a clean resolve. Entrance or emphasis punctuation; finite, seek-safe.
|
||||
metadata:
|
||||
tags: glitch, rgb-split, chromatic, slice, jitter, stutter, text, snap, distortion
|
||||
---
|
||||
|
||||
# Chromatic Glitch
|
||||
|
||||
Digital interference as punctuation: for a fraction of a second the element **breaks** — offset color copies shudder behind it, or horizontal slices displace sideways — then it **snaps sharp** and holds clean. The payoff is the resolve; the glitch exists to make the clean state land harder. Two forms: an **RGB-split jitter** (warm + cool ghost copies vibrating behind the base) and a **slice displacement** (horizontal bands that arrive offset and converge).
|
||||
|
||||
Boundaries: [motion-blur-streak.md](motion-blur-streak.md) is velocity blur tied to **travel** — its element is going somewhere fast. A glitching element is **in place**; the disturbance is temporal, not directional. [hacker-flip-3d.md](hacker-flip-3d.md) substitutes **glyphs** (a decode); here the glyphs are fixed and only displaced copies of them move.
|
||||
|
||||
## How It Works
|
||||
|
||||
The subject is stacked: the **base copy on top** (full legibility at every frame), ghost copies behind. All motion comes from one finite **amplitude-envelope** tween read by an `onUpdate`:
|
||||
|
||||
1. **Quantized time** — `const step = Math.floor(tl.time() / JITTER_STEP)`. The stutter comes from offsets that hold for `JITTER_STEP` and then jump. Smoothly interpolated offsets read as wobble, not glitch — **the quantization IS the digital texture**.
|
||||
2. **Deterministic hash** — offsets are a pure function of `(step, layerIndex)`:
|
||||
|
||||
```js
|
||||
const glitchHash = (n) => {
|
||||
const x = Math.sin(n * 127.1 + 311.7) * 43758.5453;
|
||||
return x - Math.floor(x); // 0..1, pure — a scrub to any t recomputes the same frame
|
||||
};
|
||||
```
|
||||
|
||||
3. **Amplitude envelope** — a proxy tween carries `amp: 1 → 0` over `GLITCH_DUR`. Per-frame offset = `amp × (glitchHash(step * 13 + layer * 7) * 2 − 1) × MAX_SPLIT`. When the envelope hits zero the copies sit at exactly 0 — the snap-sharp is built into the math, and a final `tl.set` clamps the rest state so the hold is bit-exact.
|
||||
|
||||
The **slice form** swaps color copies for `SLICE_COUNT` full copies, each clipped to a horizontal band via `clip-path: inset()`; per-band `x` (and optional `scaleX` stretch) start at hash-derived offsets and converge to 0 under a stepped ease.
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<!-- inside a standard scene clip (hyperframes-core) -->
|
||||
<!-- Form A: RGB-split — ghosts behind, base on top. Copies metric-identical (one grid cell, same font stack); aria-hidden on every non-base copy. -->
|
||||
<div class="glitch-stack" id="glitch-stack">
|
||||
<span class="glitch-copy warm" aria-hidden="true">{glitchText}</span>
|
||||
<span class="glitch-copy cool" aria-hidden="true">{glitchText}</span>
|
||||
<span class="glitch-base">{glitchText}</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
```css
|
||||
.glitch-stack {
|
||||
display: grid; /* all copies share one cell — pixel-identical boxes */
|
||||
}
|
||||
.glitch-base,
|
||||
.glitch-copy {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
.glitch-base {
|
||||
z-index: 2; /* grid items take z-index without position */
|
||||
color: {textColor};
|
||||
}
|
||||
.glitch-copy {
|
||||
z-index: 1;
|
||||
opacity: 0; /* raised only while the envelope is live */
|
||||
will-change: transform; /* updates every frame while live */
|
||||
mix-blend-mode: screen; /* additive on dark bg; drop to normal (and lower opacity) on light */
|
||||
}
|
||||
.glitch-copy.warm {
|
||||
color: {warmSplit}; /* classic: red/orange */
|
||||
}
|
||||
.glitch-copy.cool {
|
||||
color: {coolSplit}; /* classic: cyan/blue */
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// Form A: RGB-split jitter — envelope snaps to full amplitude, decays to zero.
|
||||
// All per-frame state derives from tl.time() + the envelope: pure, replays on seek.
|
||||
const copies = gsap.utils.toArray("#glitch-stack .glitch-copy");
|
||||
const amp = { a: 0 };
|
||||
tl.set(amp, { a: 1 }, GLITCH_START);
|
||||
tl.set(copies, { opacity: SPLIT_OPACITY }, GLITCH_START);
|
||||
tl.to(
|
||||
amp,
|
||||
{
|
||||
a: 0,
|
||||
duration: GLITCH_DUR,
|
||||
ease: "power3.in", // most of the violence up front, dying fast
|
||||
onUpdate: () => {
|
||||
const step = Math.floor(tl.time() / JITTER_STEP); // quantized — the stutter
|
||||
copies.forEach((el, layer) => {
|
||||
const jx = (glitchHash(step * 13 + layer * 7) * 2 - 1) * MAX_SPLIT * amp.a;
|
||||
const jy = (glitchHash(step * 29 + layer * 11) * 2 - 1) * MAX_SPLIT * 0.35 * amp.a;
|
||||
gsap.set(el, { x: jx, y: jy });
|
||||
});
|
||||
},
|
||||
},
|
||||
GLITCH_START,
|
||||
);
|
||||
// The clean resolve: clamp ghosts to exact rest — never rely on the decay
|
||||
// landing on zero. A ghost left 1px off reads as a bug every frame after.
|
||||
tl.set(copies, { x: 0, y: 0, opacity: 0 }, GLITCH_START + GLITCH_DUR);
|
||||
|
||||
// Form B: slice displacement — N band copies of the same content converge.
|
||||
const slices = gsap.utils.toArray("#slice-stack .slice");
|
||||
const bandH = 100 / slices.length;
|
||||
slices.forEach((el, i) => {
|
||||
gsap.set(el, { clipPath: `inset(${i * bandH}% 0 ${100 - (i + 1) * bandH}% 0)` });
|
||||
const dir = glitchHash(i * 3 + 1) > 0.5 ? 1 : -1;
|
||||
tl.fromTo(
|
||||
el,
|
||||
{
|
||||
x: dir * (SLICE_OFFSET_MIN + glitchHash(i * 5 + 2) * (SLICE_OFFSET_MAX - SLICE_OFFSET_MIN)),
|
||||
scaleX: 1 + glitchHash(i * 7 + 3) * SLICE_STRETCH,
|
||||
opacity: 1,
|
||||
},
|
||||
{ x: 0, scaleX: 1, duration: SLICE_RESOLVE_DUR, ease: "steps(SLICE_STEPS)" },
|
||||
SLICE_START + glitchHash(i * 11 + 4) * SLICE_JITTER_LAG,
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Glitch-stretch entrance** — the element ENTERS glitching: layer `fromTo(stack, { scaleX: STRETCH_FROM, opacity: 0 }, { scaleX: 1, opacity: 1, duration: GLITCH_DUR, ease: "power4.out" })` (`STRETCH_FROM` 1.3–1.8) on the whole stack while the envelope runs. Stretch, split, and envelope all die at the same frame — the word is simply _there_, sharp.
|
||||
- **Emphasis burst on a held word** — a spasm, not an arrival: 2–3 short envelopes (`GLITCH_DUR` ~0.12–0.2s each) separated by clean gaps of ~0.2–0.4s, each its own `set(amp)/to(amp)/set(rest)` triplet. The clean frames between bursts make it read as energy instead of a rendering fault.
|
||||
- **Slice reveal** — Form B as the arrival itself: bands start opaque but displaced, converge under the stepped ease. Drop the color copies for the monochrome version — the restrained enterprise read of this rule.
|
||||
- **Card / non-text glitch** — the stacked-copy machinery is content-agnostic (logo lockup, small card). Keep `MAX_SPLIT` proportional (~1% of element width) — oversized splits read as broken layout, not interference.
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| -------------------------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| MAX_SPLIT | 4–14px at headline sizes (~0.06–0.1em) | vertical ~35% of horizontal; base must stay legible at peak |
|
||||
| JITTER_STEP | 1/30–1/12 s | shorter = frantic buzz, longer = VHS stutter; **≥ one render frame** or quantization vanishes |
|
||||
| GLITCH_DUR | 0.25–0.6s entrance; 0.12–0.2s burst | ≥ ~1s stops reading as an event and starts reading as a broken render |
|
||||
| SPLIT_OPACITY | 0.5–0.9 (screen on dark) | 0.35–0.6 unblended on light — screen on white is invisible |
|
||||
| SLICE_COUNT | 4–10 | more = finer tear, diminishing past ~10 |
|
||||
| SLICE_OFFSET_MIN / MAX | 12–60px | derive per-band values from `glitchHash(i)`, never uniform — equal offsets read mechanical |
|
||||
| SLICE_STRETCH | 0–0.5 | 0 pure displacement; ~0.3 stretched-scanline read |
|
||||
| SLICE_RESOLVE_DUR / SLICE_STEPS / JITTER_LAG | 0.2–0.4s / 3–6 / ≤0.08s per band | the stepped ease keeps the settle digital |
|
||||
| {warmSplit} / {coolSplit} | — | classic red/cyan; any opposing warm+cool brand pair survives |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Quantize time — the stutter IS the effect.** Offsets hold for `JITTER_STEP` then jump; if the glitch looks like jelly, you interpolated. `JITTER_STEP` ≥ one render frame or the quantization silently disappears.
|
||||
- **Pure functions of (quantized time, index)** — every per-frame value comes from `glitchHash`; the hash inputs use `tl.time()`, nothing else.
|
||||
- **Clamp the rest state** — `tl.set({ x: 0, y: 0, opacity: 0 })` on the ghosts at envelope end; never rely on the decay landing exactly on zero.
|
||||
- **Base on top, always legible** — ghosts vibrate _behind_ the base; a glitch that destroys legibility for more than ~2 frames is a tear-down, not an accent.
|
||||
- **Brief, then clean** — the clean hold after the snap is the actual beat; `GLITCH_DUR` well under half the element's screen time. Emphasis bursts are separate finite triplets.
|
||||
- **No CSS `@keyframes` glitch loops** — the classic CSS glitch snippet runs on the wall clock and desyncs from seek; every displacement goes through the timeline's `onUpdate`.
|
||||
- **Match the register** — RGB-split is a loud consumer/tech gesture; the monochrome slice variant is the only form that belongs in a restrained enterprise composition.
|
||||
|
||||
## See also
|
||||
|
||||
`kinetic-beat-slam` (one beat lands with the glitch-stretch entrance) · `spring-pop-entrance` (pop clean, burst on the stress beat) · `gradient-text-sweep` (gradient carries the hold after the resolve) · `discrete-text-sequence` (state swap masked at max amplitude) · `motion-blur-streak` (the traveling sibling — if it's moving fast, blur it there).
|
||||
@@ -0,0 +1,140 @@
|
||||
---
|
||||
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 behavior) matches the **active text segment** — brand accent while typing the brand name, dim on placeholders, success color on the completion mark. The eye lands on the keyword being typed because the cursor shifts with it; a fixed single-color cursor is visual noise by comparison. Layers on top of [discrete-text-sequence](discrete-text-sequence.md)'s SEQUENCE pattern.
|
||||
|
||||
## How It Works
|
||||
|
||||
The text is authored as a SEQUENCE of `{ t, text, segment, color }` entries; a linear driver's `onUpdate` reverse-searches for the current entry and writes both the visible text and the cursor's `background` (the cursor is a colored block, so `background`, NOT `color`). A second linear tween sweeps a phase `p` through `2π × BLINK_CYCLES_PER_SCENE` and gates cursor opacity on `sin(p) > 0` — a deterministic square-wave blink on the timeline.
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<!-- inside a standard scene clip (hyperframes-core) -->
|
||||
<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>
|
||||
```
|
||||
|
||||
```css
|
||||
.terminal {
|
||||
font-family: {monoFont}; /* proportional fonts drift the cursor mid-segment */
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
white-space: pre; /* preserve trailing spaces — cursor sits at segment end */
|
||||
}
|
||||
.text {
|
||||
white-space: pre;
|
||||
}
|
||||
.cursor {
|
||||
display: inline-block; /* inline ignores width/height */
|
||||
width: {cursorWidth}px;
|
||||
height: {cursorHeight}px;
|
||||
background: {textColor}; /* default — overridden per segment in onUpdate */
|
||||
vertical-align: {cursorBaselineFix}px; /* small negative — anchor to baseline, not line-height */
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// Adjacent entries usually share a text prefix but may differ in `segment` —
|
||||
// that's what shifts the cursor color 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}" },
|
||||
{ t: T_BRAND_OUT, text: "{leadInBrandFull}", segment: "main", color: "{mainColor}" },
|
||||
{ t: T_CMD_IN, text: "{leadInCmdPrefix}", segment: "cmd", color: "{cmdColor}" },
|
||||
{ t: T_SUCCESS, text: "{leadInDone}", segment: "success", color: "{successColor}" },
|
||||
];
|
||||
|
||||
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");
|
||||
|
||||
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 square-wave blink
|
||||
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,
|
||||
);
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Non-blinking during active typing** — suppress blink while letters are appearing (solid cursor), resume on idle. This MUST be a pure function of the driver's time: tracking a mutable `lastChangeTime` in `onUpdate` is not reverse-seek-safe (scrubbing backwards leaves the stale forward-pass value behind and the cursor blinks — or holds solid — at the wrong frames). Bake the change times from the SEQUENCE instead — every entry whose `text` differs from its predecessor is a typing event:
|
||||
|
||||
```js
|
||||
// Baked once at build time — no runtime state.
|
||||
const CHANGE_TIMES = SEQUENCE.filter((e, i) => i > 0 && e.text !== SEQUENCE[i - 1].text).map(
|
||||
(e) => e.t,
|
||||
);
|
||||
// In onUpdate — identical result at any seek, either direction:
|
||||
const isTyping = CHANGE_TIMES.some((t) => t <= driver.t && driver.t - t < TYPING_GRACE);
|
||||
cursorEl.style.opacity = isTyping ? "1" : Math.sin(blink.p) > 0 ? "1" : "0";
|
||||
```
|
||||
|
||||
- **Cursor HEIGHT shifts on segment** — larger cursor on the brand segment: `cursorEl.style.height = entry.segment === "brand" ? cursorHeightEmphasis : cursorHeight` (1.1–1.25×; more reads as glitch).
|
||||
- **Contrast reversal** — a dark-text-on-light segment needs a dark cursor too; keep `entry.color` as the single source of truth and read from it.
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| ---------------------- | --------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| DURATION | 4–8s per typed line | `≥ SEQUENCE[last].t + closing dwell` |
|
||||
| entry `t` spacing | 0.2–0.5s micro-additions | ascending, non-uniform — slow down on highlights |
|
||||
| segment palette | 3–4 colors max | more reads as random; brand vs success should differ in saturation/luminance |
|
||||
| cursorWidth / Height | 8–24px / 0.85–1.0× fontSize | too thin vanishes in render compression; too tall outranks the text |
|
||||
| cursorBaselineFix | small negative px | drop the block to the text baseline |
|
||||
| BLINK_CYCLES_PER_SCENE | period ≈ 0.6–1.2s | **whole number** — otherwise the sin sweep ends mid-cycle and the cursor pops on the last frame |
|
||||
| TYPING_GRACE | 0.15–0.3s | **< shortest dwell between adjacent entries** — otherwise the cursor never blinks |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Cursor color goes on `background`** — it's a colored block, not a glyph.
|
||||
- **Blink is timeline-driven sin, pure of any mutable tracker** — the typing-grace variation shows the seek-safe form.
|
||||
- **`white-space: pre` on text and container** — collapsed trailing spaces park the cursor in the wrong column.
|
||||
- **Monospace font + `display: inline-block` cursor** — proportional faces drift the cursor mid-segment; inline ignores the block geometry.
|
||||
- **BLINK_CYCLES_PER_SCENE is a whole number** for the fixed DURATION.
|
||||
|
||||
## See also
|
||||
|
||||
`discrete-text-sequence` (the underlying SEQUENCE pattern) · `camera-cursor-tracking` (camera follows the cursor) · `press-release-spring` (post-typing confirm press).
|
||||
@@ -0,0 +1,131 @@
|
||||
---
|
||||
name: control-target-sync
|
||||
description: The live-sync couple — a scrubbed/typed/picked control drives a second element's property in the SAME beat. Readout tween + target transform tween share one timeline label (continuous scrub), or one threshold state array carries both sides (discrete steps). Makes "change this, watch it change" read as causality.
|
||||
metadata:
|
||||
tags: control, scrub, live-sync, mirror, panel, editor, couple, readout, ui
|
||||
---
|
||||
|
||||
# Control-Target Sync
|
||||
|
||||
THE live-editing move: an inspector/editor control is manipulated — a value scrubbed, a field retyped, a dropdown picked — and a **bound second element answers in the same frame**. The button rotates WHILE the rotation value scrubs; icons resize PER KEYSTROKE. The persuasion is causality — one gesture, two surfaces changing together — and this rule is the coupling contract that produces it.
|
||||
|
||||
Nearest precedent is [reactive-displacement.md](reactive-displacement.md): that rule also derives two elements' motion from one source, but it is **collision physics** — an entering intruder displaces an exiting victim, once, as a transition, and the victim leaves. This rule is a **live editing mirror**: the control is manipulated repeatedly across several beats, the target answers every time, and both sides hold the stage throughout. The numeric readout rides [counting-dynamic-scale.md](counting-dynamic-scale.md)'s proxy pattern; discrete steps ride [discrete-text-sequence.md](discrete-text-sequence.md)'s threshold pattern — what this rule adds is the law that binds either of them to the target.
|
||||
|
||||
## How It Works
|
||||
|
||||
An **edit beat** is a set of concurrent tweens at ONE timeline label: `tl.addLabel("edit1", …)`, then the **readout tween** (numeric proxy + `onUpdate` writing `textContent` only) and the **target transform tween** (`rotation` / `x` / `y` / `scale` to the same endpoint), both placed at the label with the same **duration** and **ease**. The two motions are two projections of one gesture — value at 40% ⇒ target at 40%, on every frame, under any seek. That mathematical lockstep reads as "the panel is editing the page," not "two animations happen to overlap."
|
||||
|
||||
For **discrete edits** (per-keystroke retypes, dropdown picks, unit snaps) the couple steps instead of glides: a single threshold state array carries BOTH sides — each state holds the readout text AND the target's property value — and one driver applies whichever state is active. Both sides read from the same state object, so they cannot desync.
|
||||
|
||||
Chain 2–4 edit beats with short holds between, and end on a **landed** edit — the last value applied and holding, never a tooltip with the dropdown unopened.
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<!-- Bipartite by construction: target surface + inspector panel share the frame.
|
||||
Every scrubbed readout gets `font-variant-numeric: tabular-nums` and a fixed
|
||||
min-width (≥ the longest value) or the panel edge jitters as digits change. -->
|
||||
<div class="target-surface">
|
||||
<div class="target-button" id="target-button">{buttonLabel}</div>
|
||||
<div class="preview-row">
|
||||
<div class="preview-icon">{iconA}</div>
|
||||
…
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="field-row">
|
||||
<span>Rotation</span><span class="field-value" id="rotation-readout">0°</span>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<span>Class</span><span class="field-value mono" id="class-readout">text-1xl</span>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
```js
|
||||
// ---- Continuous couple: ONE label; both tweens share duration AND ease ----
|
||||
tl.addLabel("edit1", EDIT1_AT);
|
||||
const rotState = { v: 0 };
|
||||
const rotReadout = document.getElementById("rotation-readout");
|
||||
tl.to(
|
||||
rotState,
|
||||
{
|
||||
v: ROT_TARGET,
|
||||
duration: SCRUB_DUR,
|
||||
ease: SCRUB_EASE,
|
||||
onUpdate: () => {
|
||||
rotReadout.textContent = `${Math.round(rotState.v)}°`;
|
||||
},
|
||||
},
|
||||
"edit1",
|
||||
);
|
||||
tl.to(
|
||||
"#target-button",
|
||||
{ rotation: ROT_TARGET, duration: SCRUB_DUR, ease: SCRUB_EASE },
|
||||
"edit1", // same label — the mirror answers in the same frame
|
||||
);
|
||||
|
||||
// ---- Discrete couple: ONE state array carries BOTH sides ----
|
||||
const STEPS = [
|
||||
{ t: 0.0, text: "text-1xl", scale: 1.0 }, // must equal the initial state
|
||||
{ t: 0.4, text: "text-4xl", scale: 1.9 },
|
||||
{ t: 1.0, text: "text-xl", scale: 0.85 }, // backspace
|
||||
{ t: 1.35, text: "text-2xl", scale: 1.3 }, // lands
|
||||
];
|
||||
const stepAt = (time) => [...STEPS].reverse().find((s) => time >= s.t) ?? STEPS[0];
|
||||
|
||||
tl.addLabel("edit3", EDIT3_AT);
|
||||
const classReadout = document.getElementById("class-readout");
|
||||
const stepDriver = { t: 0 };
|
||||
let lastStep = null;
|
||||
tl.to(
|
||||
stepDriver,
|
||||
{
|
||||
t: STEPS_TOTAL,
|
||||
duration: STEPS_TOTAL,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
const s = stepAt(stepDriver.t);
|
||||
if (s !== lastStep) {
|
||||
classReadout.textContent = s.text; // control steps
|
||||
gsap.set(".preview-icon", { scale: s.scale }); // target steps — same state object
|
||||
lastStep = s;
|
||||
}
|
||||
},
|
||||
},
|
||||
"edit3",
|
||||
);
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Dropdown pick → instant conversion (self-conversion)** — the pick converts the panel's own readout in place (`tl.set("#padding-readout", { textContent: "6 px" }, "pick")`); control and target collapse into one element. Compose the dropdown from neighbors: menu pops via [spring-pop-entrance.md](spring-pop-entrance.md), row hover-stepping via [dynamic-content-sequencing.md](dynamic-content-sequencing.md). The conversion must be an INSTANT snap — tweening between unit strings reads as broken, and instantness is the feature being sold.
|
||||
- **Easing-handle drag → target re-animates (deferred mirror)** — the edit authors a _behavior_, so the mirror is a **replay**, not a concurrent transform: beat 1 drags the handle (handle tween + coords readout), then at a later label the target performs its motion with the newly-authored curve (`tl.fromTo("#toggle-knob", { x: 0 }, { x: KNOB_TRAVEL, duration: REPLAY_DUR, ease: AUTHORED_EASE }, "replay")`), often under a zoom-out ([viewport-change.md](viewport-change.md)). The one sanctioned case where the response is not in the gesture's beat; the replay must still be unmistakably the edited parameter.
|
||||
- **Read-sync mirror (reverse direction)** — the gesture happens ON the target (hovering swatches, selecting an element) and the PANEL readout is the bound side. Same discrete contract — one state array of `{ t, hoverTarget, readout }` drives both the highlight and the text.
|
||||
- **Color couple** — the readout counts (`0 → 80`) while the target's `backgroundColor` tweens between two palette stops at the same label. Keep it two fixed stops (GSAP interpolates); never derive per-frame hex strings by hand.
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| -------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| SCRUB_DUR | 0.8–1.6 s | the viewer must see BOTH sides move — under ~0.6 s the mirror registers subconsciously at best |
|
||||
| SCRUB_EASE | `power1.inOut` / `power2.inOut` | shared verbatim by both tweens. Never `back.out` / `elastic.out` — an overshooting value reads as a broken hinge; the readout is data |
|
||||
| edit endpoints | visible but plausible | −10° tilt, 38 px shift, 1xl → 4xl → 2xl; a 2° rotation doesn't demo anything |
|
||||
| HOLD_BETWEEN | 0.3–0.8 s | each landed value gets a breath; below 0.3 s the beats smear into one gesture |
|
||||
| BEAT_COUNT | 2–4 | one edit is a moment, not a demo; past 4 the shot reads as a settings tour |
|
||||
| STEP gaps (discrete) | 0.15–0.5 s | keystroke pacing per discrete-text-sequence; first state must equal the on-load state |
|
||||
| VALUE_MIN_WIDTH | ≥ longest value's width | without it the panel edge jitters as digit counts change |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **One label, one gesture** — readout tween and target tween share position, duration, AND ease; never sequence readout-then-target, and never stagger the target behind the readout even by 0.1 s — a delayed response reads as an animation following an edit, not a bound surface. A mismatched ease desyncs the mirror mid-tween even when endpoints agree.
|
||||
- **Discrete steps share one state object** — both sides read the same array entry, so desync is impossible by construction; first entry mirrors the initial DOM state.
|
||||
- **The readout is data** — no overshoot, no bounce on the settle; the target may carry the gesture's ease but lands exactly on the edited value.
|
||||
- **Co-visibility is load-bearing** — control and target share the frame for every edit beat; a camera move must never crop the mirror out (punch-and-return around the beats, not through them).
|
||||
- **`tabular-nums` + fixed `min-width`** on every scrubbed readout; `onUpdate` is O(1) — text writes only, discrete drivers guard writes with a last-state check.
|
||||
- **End on a landed edit** — the final beat resolves with the value applied and holding (or the deferred-mirror replay); never mid-gesture or on an unopened menu.
|
||||
- **The gesture's actor is a separate rule** — cursor glide, grab-cursor flip, and click feedback come from the cursor rules; this rule owns only the couple.
|
||||
|
||||
## See also
|
||||
|
||||
`cursor-click-ripple` / `context-sensitive-cursor` (the hand performing the gesture) · `counting-dynamic-scale` (the readout half alone, when there is no bound target) · `discrete-text-sequence` (retypes inside the control field) · `spring-pop-entrance` (dropdowns/chrome around the couple) · `multi-phase-camera` (punch-and-return framing) · `chart-scrub-readout` (the sibling READ direction — a scrub interrogates a chart instead of editing a target).
|
||||
@@ -0,0 +1,138 @@
|
||||
---
|
||||
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 — never scale and translate on the SAME element (`translate * scale` ≠ `scale * translate` in CSS transform composition):
|
||||
|
||||
1. **Outer wrapper** applies `scale` (the zoom) around `transform-origin: 50% 50%`
|
||||
2. **Inner wrapper** applies `translate(x, y)` (the counter-shift)
|
||||
|
||||
The counter-translate is the **negation** of the target's offset from viewport center:
|
||||
|
||||
```
|
||||
T = -offset
|
||||
```
|
||||
|
||||
Derivation: the inner translate moves the target to `offset + T` in pre-scale units; the outer scale S (around center) maps that to `S × (offset + T)`; landing at center means `S × (offset + T) = 0` → **`T = -offset`**. The formula does NOT depend on S — the translate is identical at 1.5×, 2×, or 3×. A common wrong intuition is `T = -offset × (S - 1)`: it coincidentally matches at S = 2 and is wrong at every other scale.
|
||||
|
||||
⚠️ **This is the NESTED-wrapper formula.** The single-wrapper camera in [viewport-change.md](viewport-change.md) puts `translate(x,y) scale(S)` on ONE element, where CSS applies scale first — there the counter-translate is **`T = -offset × S`**. The two formulas are not interchangeable; match the formula to the wrapper structure.
|
||||
|
||||
## 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 actual center (works for ANY layout).** Immune to sign errors because it reads the rendered DOM, not a mental model:
|
||||
|
||||
```js
|
||||
await document.fonts.ready; // metrics final; fallback fonts are 10–30px off → tens of px after a 3×+ zoom
|
||||
const W = 1920,
|
||||
H = 1080;
|
||||
const r = document.getElementById("target-card").getBoundingClientRect();
|
||||
const TARGET_OFFSET_X = r.left + r.width / 2 - W / 2;
|
||||
const TARGET_OFFSET_Y = r.top + r.height / 2 - H / 2;
|
||||
```
|
||||
|
||||
Measure **once at setup** and bake — never per-frame in `onUpdate`. 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:**
|
||||
|
||||
```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, it gives the wrong answer — often the wrong **sign**: the heavier side shifts the centered target the _opposite_ way you'd guess (e.g. `companion(220) + gap + wordmark + gap + chip(110)` puts the wordmark ~55px **right** of center, but "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; keep the target ≤ ~88% of the canvas at peak:
|
||||
|
||||
```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 filling 97%+ of the frame reads as cut-off the instant its center is slightly off — and a hand-baked offset always is. (The perception gate flags this as `primary-offscreen`; `data-layout-allow-overflow` does **not** exempt it.)
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<div class="zoom-outer" id="zoom-outer">
|
||||
<div class="zoom-inner" id="zoom-inner">
|
||||
<div class="content">
|
||||
<div class="card">{other}</div>
|
||||
<div class="card target" id="target-card">{target}</div>
|
||||
<div class="card">{other}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
```css
|
||||
.scene {
|
||||
overflow: hidden; /* REQUIRED — at zoom > 1 the scaled content leaks past the frame */
|
||||
}
|
||||
.zoom-outer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transform-origin: 50% 50%; /* center scaling is what the counter-translate math assumes */
|
||||
will-change: transform;
|
||||
}
|
||||
.zoom-inner {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
will-change: transform;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// TARGET_OFFSET_X/Y and ZOOM_SCALE come from "Getting the offset" — measured
|
||||
// at setup (after fonts.ready), baked. Counter-translation = -offset.
|
||||
const counterX = -TARGET_OFFSET_X;
|
||||
const counterY = -TARGET_OFFSET_Y;
|
||||
|
||||
// Scale and counter-translate MUST share position, duration, AND ease —
|
||||
// otherwise the target visibly wanders mid-zoom.
|
||||
tl.to("#zoom-outer", { scale: ZOOM_SCALE, duration: ZOOM_DUR, ease: "power3.inOut" }, ZOOM_AT);
|
||||
tl.to(
|
||||
"#zoom-inner",
|
||||
{ x: counterX, y: counterY, duration: ZOOM_DUR, ease: "power3.inOut" },
|
||||
ZOOM_AT,
|
||||
);
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Zoom out (target → wide view)**: reverse the phases — start zoomed-in, then tween to `scale: 1` + `x: 0, y: 0`; the "reveal" beat is the panorama.
|
||||
- **Multi-target zoom sequence**: chain zooms (target A → pause → target B → pull back); each segment needs its own counter-translation pair.
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| ---------- | --------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| ZOOM_SCALE | 1.5× modest → 3× dominant → 5×+ extreme | cap via the headroom budget; raster media needs `sourceResolution ≥ rendered × ZOOM_SCALE` |
|
||||
| ZOOM_DUR | 1.0–2.0s | under 0.8s feels like a teleport, over 2.5s drags; both tweens share it |
|
||||
| ZOOM_AT | after the layout lands + 0.5–1.5s | give the viewer time to scan the layout before the camera commits |
|
||||
| DWELL | ≥ 1.0s after the zoom settles | 1.5–2s ideal — the viewer must be able to read the target (climax dwell) |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Outer scales, inner translates** — never both transforms on one element; nested wrappers keep the math clean.
|
||||
- **`transform-origin: 50% 50%` on the outer wrapper** — non-center origin breaks the counter-translate derivation.
|
||||
- **`overflow: hidden` on the scene root** — zoomed content leaks past the frame otherwise.
|
||||
- **Scale and counter-translate share duration + ease** at the same timeline position, or the target drifts mid-zoom.
|
||||
- **Offset measured once at setup** (after `fonts.ready`), baked — never recomputed per-frame, never hand-derived for a non-symmetric layout (wrong sign → target shoved off-frame).
|
||||
- **Scale within the headroom budget** — target ≤ ~88% of the canvas at peak, derived from the measured size.
|
||||
|
||||
## See also
|
||||
|
||||
[viewport-change.md](viewport-change.md) (single-wrapper form, `T = -offset × S`) · [multi-phase-camera.md](multi-phase-camera.md) (a zoom phase inside a phased camera) · [sine-wave-loop.md](sine-wave-loop.md) (idle breathing after the zoom settles) · [discrete-text-sequence.md](discrete-text-sequence.md) (text assembly in the target before the zoom).
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
name: counting-dynamic-scale
|
||||
description: Counter animation where the value counts up while transform scale grows to its final size, creating escalating visual weight without per-frame text reflow.
|
||||
metadata:
|
||||
tags: counter, counting, scale, transform, number, dynamic, emphasis
|
||||
---
|
||||
|
||||
# Counting with Dynamic Scale
|
||||
|
||||
A number counts from A → B while its transform scale grows to the final size — escalating visual weight ("this is impressive") without tweening `font-size` or forcing text layout on every frame. The final font size is static CSS; only the transform changes.
|
||||
|
||||
## How It Works
|
||||
|
||||
Two synchronized tweens at the SAME timeline position with the SAME ease: (1) a proxy value rendered as text via `onUpdate` (`Math.round(...).toLocaleString()`), (2) the counter's transform `scale: START_SCALE → 1`, where `START_SCALE = START_SIZE / END_SIZE`. A suffix (`%`, `×`, `+`) slides in AFTER the count lands — the number gets its own beat — and a label fades in early.
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<!-- inside a standard scene clip (hyperframes-core) -->
|
||||
<div class="counter-wrap">
|
||||
<span class="counter" id="counter">0</span><span class="counter-suffix">{suffix}</span>
|
||||
</div>
|
||||
<div class="counter-label">{label}</div>
|
||||
```
|
||||
|
||||
```css
|
||||
.counter-wrap {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
width: {counterContainerWidth}; /* fixed width — no layout shift as digit count changes */
|
||||
}
|
||||
.counter {
|
||||
font-variant-numeric: tabular-nums; /* MANDATORY — digits keep equal width */
|
||||
display: inline-block;
|
||||
font-size: {endSize}; /* final size is static; GSAP animates scale, not font-size */
|
||||
transform-origin: center center;
|
||||
}
|
||||
.counter-suffix {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
const counter = document.getElementById("counter");
|
||||
const state = { value: 0 };
|
||||
const START_SCALE = START_SIZE / END_SIZE;
|
||||
|
||||
// Count value — onUpdate changes text only
|
||||
tl.to(
|
||||
state,
|
||||
{
|
||||
value: TARGET_VALUE,
|
||||
duration: COUNT_DUR,
|
||||
ease: COUNT_EASE,
|
||||
onUpdate: () => {
|
||||
counter.textContent = Math.round(state.value).toLocaleString();
|
||||
},
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
// Visual growth — compositor transform sharing the count's timing + ease
|
||||
tl.fromTo(counter, { scale: START_SCALE }, { scale: 1, duration: COUNT_DUR, ease: COUNT_EASE }, 0);
|
||||
|
||||
// Suffix slides in AFTER the 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);
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Direct `innerText` tween (no proxy)** — GSAP can tween `innerText` directly for a number-only counter; keep the proxy form when you need locale formatting or suffix logic. The scale tween stays separate either way:
|
||||
|
||||
```js
|
||||
tl.to(
|
||||
counter,
|
||||
{ innerText: TARGET_VALUE, duration: COUNT_DUR, ease: COUNT_EASE, snap: { innerText: 1 } },
|
||||
0,
|
||||
);
|
||||
```
|
||||
|
||||
- **3D depth entry** — add a `tl.from(".counter", { z: -300, ... }, 0)` push-in; requires `perspective` on `.counter-wrap` and `transform-style: preserve-3d` on the counter.
|
||||
- **Multi-stat coordinated reveal** — 3 stats counting in parallel share the SAME ease, duration, and start position so they finish together (a chord, not an 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).
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| --------------------- | ------------------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| TARGET_VALUE | 2–3 digits ideal | 4+ digits needs a wider container; must fit at END_SIZE without clipping |
|
||||
| START_SIZE / END_SIZE | START ≈ 40–60% of END | design inputs used once for START_SCALE; never tween either |
|
||||
| COUNT_DUR | 1.2–2.5s | below ~0.8s reads as a flash — the eye must read the digits scrolling past |
|
||||
| COUNT_EASE | `power2.out` / `power3.out` ⭐ / `expo.out` | shared by value + scale; more `.out` = more dramatic deceleration at the peak |
|
||||
| SUFFIX_DUR | 0.3–0.6s | fires at `COUNT_DUR`, never during the count |
|
||||
| SUFFIX_BOUNCE_FACTOR | 1.4–2.0 | overshoot is fine on the suffix (it's punctuation, not data) |
|
||||
| LABEL_AT / LABEL_DUR | AT < COUNT_DUR/2; 0.4–0.7s | label arrives before the count peaks |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **`tabular-nums` mandatory** + fixed-width container as belt-and-suspenders — without them digit-count transitions (9 → 10 → 100) jitter as glyph widths change.
|
||||
- **Never set `fontSize` in `onUpdate`** — final type size is static CSS; only the transform changes per frame. Keep `onUpdate` O(1): set text only, no style writes or DOM creation.
|
||||
- **`Math.round`, not `Math.floor`** — halfway through the final integer should already display the final value.
|
||||
- **Avoid `back.out` / `elastic.out` on the counter itself** — overshoot makes the number look unstable (it's data, not decoration). Grow in place, don't bounce.
|
||||
- **Label is BIG TEXT, not a page-style caption** — a tiny paragraph under a hero-size number reads as visual noise in video. Display-size, uppercase, tracked: the label is part of the headline.
|
||||
|
||||
## See also
|
||||
|
||||
`stat-bars-and-fills` (the paired graphic — give it the same ease/duration so number and fill land as one beat) · `svg-path-draw` (icons drawing in around the number) · `center-outward-expansion` (icons bursting outward at the count peak).
|
||||
@@ -0,0 +1,208 @@
|
||||
# CSS Patterns for Marker Highlighting
|
||||
|
||||
Pure CSS + GSAP implementations of all five MarkerHighlight.js drawing modes — no external library dependency, full timeline control. Snippets show mechanism DOM only, inside a standard scene clip (hyperframes-core); assume `tl` exists.
|
||||
|
||||
Shared scaffold for every mode: the wrap is `position: relative; display: inline`; the text copy is `position: relative` and z-indexed **above** the accent (below it for sketchout, where the lines cross the text).
|
||||
|
||||
## 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-bar {
|
||||
position: absolute;
|
||||
inset: 0 -6px; /* bleed past the text edges */
|
||||
background: #fdd835;
|
||||
opacity: 0.35;
|
||||
transform: scaleX(0);
|
||||
transform-origin: left center;
|
||||
border-radius: 3px;
|
||||
z-index: 0;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
tl.to("#hl-1", { scaleX: 1, duration: 0.5, ease: "power2.out" }, 0.6);
|
||||
// Optional hand-drawn skew: gsap.set("#hl-1", { skewX: -2 });
|
||||
// Multi-line: tl.to(".mh-highlight-bar", { scaleX: 1, ..., stagger: 0.3 }, 0.6);
|
||||
```
|
||||
|
||||
## 2. Circle Mode
|
||||
|
||||
Hand-drawn ellipse around text — `border-radius: 50%` plus a slight rotation for organic feel.
|
||||
|
||||
```html
|
||||
<span class="mh-circle-wrap">
|
||||
<span class="mh-circle-text">IMPORTANT</span>
|
||||
<span class="mh-circle-ring" id="circle-1"></span>
|
||||
</span>
|
||||
```
|
||||
|
||||
```css
|
||||
.mh-circle-ring {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 130%; /* tight (short words): 150%; rounded-rect: 120% + border-radius: 30% */
|
||||
height: 160%;
|
||||
transform: translate(-50%, -50%) rotate(-3deg) scale(0);
|
||||
border: 3px solid #e53935;
|
||||
border-radius: 50%;
|
||||
z-index: 0;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
tl.to("#circle-1", { scale: 1, rotation: -3, duration: 0.6, ease: "back.out(1.7)" }, 0.7);
|
||||
```
|
||||
|
||||
## 3. Burst Mode
|
||||
|
||||
Radiating lines from text center — each line a positioned span rotated to its angle. Use ~12 lines at 30° steps and **vary `--len` (40–80px)**; equal lengths look mechanical.
|
||||
|
||||
```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>
|
||||
<!-- …one line per 30° step through 330deg, --len varied 40-80px -->
|
||||
</span>
|
||||
</span>
|
||||
```
|
||||
|
||||
```css
|
||||
.mh-burst-container {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: 1; /* text copy at z-index: 2 */
|
||||
}
|
||||
.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
|
||||
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,
|
||||
);
|
||||
```
|
||||
|
||||
## 4. Scribble Mode
|
||||
|
||||
Wavy SVG underline that draws itself 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>
|
||||
</span>
|
||||
```
|
||||
|
||||
```css
|
||||
.mh-scribble-svg {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: -6px; /* strikethrough variant: top: 50%; transform: translateY(-50%) */
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
z-index: 0;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
const path = document.querySelector("#scribble-1");
|
||||
const len = path.getTotalLength();
|
||||
gsap.set(path, { strokeDasharray: len, strokeDashoffset: len });
|
||||
tl.to("#scribble-1", { strokeDashoffset: 0, duration: 0.8, ease: "power1.inOut" }, 0.7);
|
||||
```
|
||||
|
||||
Path tuning: the `Q` control points alternate y between 0 and 24 for a natural wobble. Tighter waves = smaller x-increments (~25px per half-wave); looser = ~50px; subtler amplitude = y range 0–16.
|
||||
|
||||
## 5. Sketchout Mode
|
||||
|
||||
Cross-hatch over de-emphasized text — two 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-lines {
|
||||
position: absolute;
|
||||
inset: 0 -4px;
|
||||
overflow: hidden;
|
||||
z-index: 1; /* text at z-index: 0 — the lines cross OVER it */
|
||||
}
|
||||
.mh-sketchout-line {
|
||||
position: absolute;
|
||||
display: block;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: #e53935;
|
||||
transform-origin: left center;
|
||||
}
|
||||
.mh-sketchout-fwd {
|
||||
transform: scaleX(0) rotate(-12deg);
|
||||
}
|
||||
.mh-sketchout-bwd {
|
||||
transform: scaleX(0) rotate(12deg);
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// Forward slash first, backward follows
|
||||
tl.to("#sketchout-1 .mh-sketchout-fwd", { scaleX: 1, duration: 0.3, ease: "power2.out" }, 1.0);
|
||||
tl.to("#sketchout-1 .mh-sketchout-bwd", { scaleX: 1, duration: 0.3, ease: "power2.out" }, 1.15);
|
||||
```
|
||||
|
||||
## Combining Modes in Captions
|
||||
|
||||
Cycle modes across caption groups for visual variety — every 2-3 groups for high energy, 3-4 for medium, 4-5 for low:
|
||||
|
||||
```js
|
||||
const MODES = ["highlight", "circle", "burst", "scribble"];
|
||||
GROUPS.forEach((group, gi) => {
|
||||
const mode = MODES[gi % MODES.length];
|
||||
group.emphasisWords.forEach((word) => applyMode(word.el, mode, tl, word.start));
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
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. Three sequential phases on one timeline: **move** (eased translation to the target's center) → **click** (scale depression on cursor + target together, yoyo back) → **ripple** (1–3 staggered rings expand and fade from the click point). This is a _point event at one location_ — a sustained hold across space is [cursor-drag.md](cursor-drag.md).
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<button class="target-button">{ctaLabel}</button>
|
||||
<div class="cursor"><!-- arrow SVG, positioned at the entry corner --></div>
|
||||
<!-- Rings live in DOM from t=0 at the click-target CENTER, scale 0 + opacity 0 -->
|
||||
<div class="ripple ripple-1"></div>
|
||||
<div class="ripple ripple-2"></div>
|
||||
<div class="ripple ripple-3"></div>
|
||||
```
|
||||
|
||||
```css
|
||||
.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;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// Phase 1 — Move: 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, // holds scale 0 / opacity 0 until the click moment
|
||||
},
|
||||
RIPPLE_AT,
|
||||
);
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Single ring** — one `.ripple`, no stagger; more elegant when the rest of the scene is busy.
|
||||
- **Keyframed attack-decay** — a `keyframes` block ramps opacity 0 → peak → 0 across the duration; a clearer "energy radiates and dissipates" envelope.
|
||||
- **Multi-ring expanding pulse** — 3 rings at 0.08 s stagger when the click is the scene's climactic moment.
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| --------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| MOVE_DUR | 0.4–1.0 s | short darts; long reads as a "considered click." Must end before CLICK_AT or it reads as a misclick |
|
||||
| MOVE_EASE | discrete choice | `power2.inOut` calm · `power3.out` decisive · `back.out(1.2–1.4)` settles onto the button with a tiny recoil (higher reads cartoonish) |
|
||||
| CLICK_AT | `MOVE_DUR + 0–0.3 s` | zero pause reads as autopilot; >0.3 s reads as hesitation |
|
||||
| PRESS_DUR | 0.06–0.12 s (half; yoyo ×2) | short crisp, long mushy; must finish before the next phase needs normal scale |
|
||||
| CURSOR / TARGET_PRESS_SCALE | 0.80–0.90 / 0.92–0.97 | cursor compresses MORE than the target — the cursor is the actor, the target the recipient |
|
||||
| RIPPLE_AT | `CLICK_AT + 0–0.08 s` | simultaneous feels causal; slight delay feels acoustic |
|
||||
| RIPPLE_DUR | 0.5–1.0 s | sharp ping vs soft sonar; must complete before anything that needs the ring gone |
|
||||
| RIPPLE_SCALE | 3–6 | 3 stays near the click site; if the ring would exit the frame before fading, lower it |
|
||||
| RIPPLE_STAGGER | 0.06–0.12 s (or 0) | below ~0.06 s reads as one thick ring; above ~0.12 s as separate events |
|
||||
| RIPPLE_EASE | discrete choice | `power2.out` standard ping · `power3.out` sharper attack · `expo.out` strong distant pulse |
|
||||
| TARGET_X / TARGET_Y | layout-derived | must match the target's visual centroid — a 4 px miss reads as missing the button |
|
||||
|
||||
Reference values: `../../examples/cta-orbit-collapse.html` — 0.5 s move on `back.out(1.3)`, click +0.2 s, press 0.08 s at 0.85/0.95, single ring to 5× over 0.7 s `power2.out`.
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Move before click** — trigger the click only after the move tween settles; clicking mid-motion reads as unintentional.
|
||||
- **Rings live in DOM from t=0** at the click-target center with `scale: 0` + `opacity: 0` — never conditionally rendered; `immediateRender: false` on the expand so they hold invisible until the trigger.
|
||||
- **Ripple from the click point** — the button's visual center, not any element's bounding-box origin.
|
||||
- **Synchronized depression** — cursor + target depress at the same position with the same duration, and both yoyo back.
|
||||
- **Cursor above all content** (high z-index) for the whole sequence; `pointer-events: none` on cursor + ripples.
|
||||
|
||||
## See also
|
||||
|
||||
`orbit-3d-entry` (click as the pivot that collapses orbiters) · `center-outward-expansion` (click triggers an outward burst) · `press-release-spring` (stronger physical feel on the target) · `scale-swap-transition` (the button's post-click state change).
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
name: cursor-drag
|
||||
description: The drag verb for driven cursors — grab, lift, travel, drop-snap. A semi-transparent ghost chip rides the cursor in exact lockstep and snaps into a placed field with selection chrome; variants cover fill-handle auto-fill down rows, corner-handle proportional resize (uniform scale only), and grab-lift-reorder with the neighbor springing into the vacated slot.
|
||||
metadata:
|
||||
tags: cursor, drag, drop, ghost, handle, resize, reorder, snap, interaction, mouse
|
||||
---
|
||||
|
||||
# Cursor Drag
|
||||
|
||||
> Cursor look, sizing, off-screen entry, and tip-targeting defer to the **oversized-cursor house doctrine** — this rule owns the drag _mechanics_ only.
|
||||
|
||||
THE held-journey verb: the cursor presses down on a payload, carries it, and releases it somewhere else. The load-bearing law is **lockstep**: the cursor tip and the payload's grip point move as one rigid object for the entire travel — a one-frame drift reads as the chip slipping out of the hand. Distinct from [cursor-click-ripple.md](cursor-click-ripple.md) (move → point event at a single location): a drag is a _sustained hold across space_, and the payload is the co-star. Reuse [physics-press-reaction.md](physics-press-reaction.md) for the grab's press dip (cursor + payload compress together); for N simultaneous actors see [multi-cursor-choreography.md](multi-cursor-choreography.md) — this rule is one protagonist performing a workflow beat.
|
||||
|
||||
## How It Works
|
||||
|
||||
Five beats: **approach** (cursor glides to the source chip, `power2.inOut`) → **grab** (press dip on cursor + chip together; on the down-beat `tl.set` reveals the **ghost** — a pre-rendered semi-transparent clone at the chip's position — plus a small lift `fromTo` to `GHOST_LIFT_SCALE` with a soft shadow, `immediateRender: false`) → **travel** (cursor and ghost move as **matched tweens**) → **drop** (ghost off, placed field pops in with selection chrome) → **adjust / exit** (optional handle resize, then the cursor glides to the next target).
|
||||
|
||||
Matched tweens = same timeline position, same duration, same ease, over straight lines — that keeps the pair rigidly locked at every eased midpoint. A shared `[cursor, ghost]` targets array only works when both need identical deltas; with different start points, use two matched `fromTo`s. Rule-specific corollary of the contract's absolute-values law: a relative `+=` travel on either partner breaks the lockstep under seek.
|
||||
|
||||
Measure chip and slot rects at build time — a 4 px miss on the drop line reads as a failed drag (montage: authored CSS-matched constants, per the contract). `TIP_OFFSET_X/Y` aligns the cursor's TIP (not its bbox) with the grip point.
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<!-- Ghost = clone of the chip AT the chip's position, in DOM from t=0, opacity: 0.
|
||||
Same silhouette as the chip — or hand and payload read as different objects.
|
||||
Placed field sits at the slot's final position, opacity: 0, with a .select-box
|
||||
and four corner .handle elements inside. -->
|
||||
<div class="tray-chip" id="source-chip"><span class="grip-dots">⋮⋮</span> {chipLabel}</div>
|
||||
<div class="drag-ghost" id="drag-ghost"><span class="grip-dots">⋮⋮</span> {chipLabel}</div>
|
||||
<div class="placed-field" id="placed-field">
|
||||
{placedLabel}
|
||||
<!-- + selection chrome -->
|
||||
</div>
|
||||
<div class="cursor" id="cursor"><!-- arrow SVG --></div>
|
||||
```
|
||||
|
||||
```js
|
||||
const chipRect = document.querySelector("#source-chip").getBoundingClientRect();
|
||||
const slotRect = document.querySelector("#placed-field").getBoundingClientRect();
|
||||
const TRAVEL_DX = slotRect.left - chipRect.left;
|
||||
const TRAVEL_DY = slotRect.top - chipRect.top;
|
||||
|
||||
// Travel — MATCHED tweens: same position, duration, ease; absolute endpoints.
|
||||
tl.fromTo(
|
||||
"#drag-ghost",
|
||||
{ x: 0, y: 0 },
|
||||
{ x: TRAVEL_DX, y: TRAVEL_DY, duration: TRAVEL_DUR, ease: TRAVEL_EASE, immediateRender: false },
|
||||
TRAVEL_AT,
|
||||
);
|
||||
tl.fromTo(
|
||||
"#cursor",
|
||||
{ x: chipRect.left + TIP_OFFSET_X, y: chipRect.top + TIP_OFFSET_Y },
|
||||
{
|
||||
x: chipRect.left + TIP_OFFSET_X + TRAVEL_DX,
|
||||
y: chipRect.top + TIP_OFFSET_Y + TRAVEL_DY,
|
||||
duration: TRAVEL_DUR,
|
||||
ease: TRAVEL_EASE,
|
||||
immediateRender: false,
|
||||
},
|
||||
TRAVEL_AT,
|
||||
);
|
||||
|
||||
// Drop is a state commit: ghost off + placed field on at the SAME position.
|
||||
tl.set("#drag-ghost", { opacity: 0 }, DROP_AT);
|
||||
tl.fromTo(
|
||||
"#placed-field",
|
||||
{ opacity: 0, scale: 0.92 },
|
||||
{ opacity: 1, scale: 1, duration: SNAP_DUR, ease: "power3.out" },
|
||||
DROP_AT,
|
||||
);
|
||||
tl.fromTo(
|
||||
[".select-box", ".handle"],
|
||||
{ opacity: 0, scale: 0.6 },
|
||||
{ opacity: 1, scale: 1, duration: 0.18, ease: "power3.out", stagger: 0.02 },
|
||||
DROP_AT + SNAP_DUR * 0.4,
|
||||
);
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Corner-handle proportional resize** — width/height tweens are forbidden, so the resize renders as uniform `scale` with `transform-origin` at the **opposite (anchor) corner**: the anchor stays put, the dragged corner travels. The corner's position is _linear in scale_ (`corner = anchor + scale × (corner₀ − anchor)`), so a cursor tween to the corner's end position with the **same duration and ease** stays glued to the handle exactly:
|
||||
|
||||
```js
|
||||
tl.to(
|
||||
"#placed-field",
|
||||
{ scale: RESIZE_SCALE, transformOrigin: "0% 0%", duration: RESIZE_DUR, ease: "power2.inOut" },
|
||||
RESIZE_AT,
|
||||
);
|
||||
tl.to(
|
||||
"#cursor",
|
||||
{ x: CORNER_END_X, y: CORNER_END_Y, duration: RESIZE_DUR, ease: "power2.inOut" },
|
||||
RESIZE_AT,
|
||||
);
|
||||
```
|
||||
|
||||
One-axis resizes are `scaleX`/`scaleY` on the same origin logic — stretch-safe boxes only; route to [anchored-layout-expand.md](anchored-layout-expand.md)'s counter-scale when content must stay undistorted.
|
||||
|
||||
- **Fill-handle auto-fill** — the spreadsheet verb: the cursor drags a cell's fill handle straight down on a `"none"` (linear) ease; each row commits via a snapped `tl.set` (never a fade) keyed to the handle's linear progress, so the fill edge and cursor never separate:
|
||||
|
||||
```js
|
||||
tl.fromTo(
|
||||
"#cursor",
|
||||
{ y: HANDLE_Y },
|
||||
{ y: HANDLE_Y + FILL_DIST, duration: FILL_DUR, ease: "none", immediateRender: false },
|
||||
FILL_AT,
|
||||
);
|
||||
gsap.utils.toArray(".fill-cell").forEach((cell, i) => {
|
||||
tl.set(cell, { opacity: 1 }, FILL_AT + ((i + 1) / CELL_COUNT) * FILL_DUR);
|
||||
});
|
||||
```
|
||||
|
||||
- **Grab-lift-reorder** — lift = `y: -LIFT_RISE` + `rotation: LIFT_TILT` (sign from index parity) + shadow on; as the carried item crosses the neighbor's midpoint, the **neighbor springs into the vacated slot** (a `fromTo` translate at `TRAVEL_AT + TRAVEL_DUR * 0.5`, `power3.out`); drop = rotation → 0, shadow off, settle. The neighbor's counter-move sells the reorder — without it the list reads as broken.
|
||||
- **Component grab between surfaces** — a chip dragged mockup-to-mockup, swapping identity on drop (`tl.set` recolor + label swap at `DROP_AT`, tiny settle pop); the drop chrome is just the identity swap, no handles.
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| --------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| approach / press | per cursor-click-ripple | approach 0.4–1.0 s; press-dip halves 0.06–0.12 s; cursor compresses more than the payload |
|
||||
| GHOST_OPACITY | 0.5–0.75 | below 0.5 vanishes on busy documents; ~1.0 reads as the original moving — then hide `#source-chip` at the grab |
|
||||
| GHOST_LIFT_SCALE / LIFT_DUR | 1.03–1.08 / 0.12–0.2 s | the shadow is the "off the surface" cue; the scale is garnish |
|
||||
| TRAVEL_DUR / TRAVEL_EASE | 0.6–1.2 s / `power2.inOut` | a considered drag decelerates into the slot; `power1.inOut` for a calmer carry. `TRAVEL_AT ≥ GRAB_AT + 2×PRESS_DUR + LIFT_DUR` |
|
||||
| DROP_AT / SNAP_DUR | `TRAVEL_AT + TRAVEL_DUR` exactly / 0.2–0.3 s | a gap between arrival and snap reads as the drop failing |
|
||||
| RESIZE_SCALE / RESIZE_DUR | by story (≈0.4–0.6) / 0.6–1.0 s | `power2.inOut` |
|
||||
| LIFT_RISE / LIFT_TILT | 6–12 px / 2–4° | reorder pickup; index-derived tilt sign |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Lockstep is the law** — matched tweens over straight lines (or one shared tween when deltas are identical); verify at the eased midpoint, not just the endpoints. Absolute endpoints on both partners.
|
||||
- **The ghost is pre-rendered** — a DOM clone at the source position from t=0, `opacity: 0`, revealed by `tl.set`; placed field and chrome likewise. Never cloned at runtime, never conditionally rendered.
|
||||
- **Grab has weight** — press dip + lift shadow before any travel; a chip departing without a press reads as telekinesis.
|
||||
- **Drop is a state commit** — ghost off and placed field on at the same timeline position, `DROP_AT = TRAVEL_AT + TRAVEL_DUR`.
|
||||
- **Resizes are uniform `scale`, origin at the anchor corner** — never width/height; one-axis stretch on stretch-safe boxes only.
|
||||
- **Linear ease on the fill-handle travel** — the evenly-spaced `tl.set` reveals depend on it; an eased handle bunches them at the ends.
|
||||
- **One verb per beat** — drag, then resize, then exit; overlapping a travel with a resize turns choreography into mush.
|
||||
- **`pointer-events: none`** on cursor, ghost, and chrome.
|
||||
|
||||
## See also
|
||||
|
||||
`physics-press-reaction` (the grab's press dip) · `cursor-click-ripple` (a plain click before/after) · `spring-pop-entrance` (the placed field's snap-settle) · `waterfall-entry` (kinetic fill cascade) · `multi-phase-camera` (the zoom-breathing carrier shot golden drag demos ride) · `multi-cursor-choreography` (this verb inside an ensemble).
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
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. `filter` and `opacity` are paint-only, so both tween seek-safe. This is the backing rule for the focus-falloff beat the blueprints reach for: outer nodes blurring during a push-in (`constellation-hub`), rack-focus across a parallax card stack (`cursor-ui-demo`), non-highlighted cards dimming to spotlight a hero metric (`dataviz-countup`).
|
||||
|
||||
## How It Works
|
||||
|
||||
Every layer carries a `--dof` custom property (px of blur), read by `filter: blur(var(--dof))`, plus its own `opacity`. A 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 `--dof` stays `0`. Per-layer targets derive from `data-depth` / index, so the falloff is identical on every seek.
|
||||
|
||||
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: plane A's blur ramps 0 → max at the same position plane B's ramps max → 0. State continuity matters exactly as in `press-release-spring`: A's resting blur after the rack must equal what B held before it — author both as tweens on the same `--dof` at the same position so the hand-off is seamless.
|
||||
3. **Blur-the-cluster-while-pushing-in** — the DoF tween runs at the SAME timeline position as a camera push-in (`multi-phase-camera` / `coordinate-target-zoom`): "the world recedes" and "we push in" read as one move.
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<div class="world" id="world">
|
||||
<!-- Focal layer — stays sharp -->
|
||||
<div class="layer focal" id="focal">{FocalLabel}</div>
|
||||
<!-- Off-focus layers — blur + dim; data-depth orders near→far -->
|
||||
<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>
|
||||
```
|
||||
|
||||
```css
|
||||
.world {
|
||||
/* single wrapper so a concurrent camera push-in transforms everything
|
||||
together; DoF is independent of the camera */
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
.layer {
|
||||
--dof: 0px; /* px of blur; filter reads it — starts sharp */
|
||||
filter: blur(var(--dof));
|
||||
will-change: filter; /* promotes the layer so per-frame re-rasterization is cheap */
|
||||
}
|
||||
.focal {
|
||||
z-index: 2; /* sharp layer must sit ABOVE the blurred ones, or its crisp
|
||||
edges read as bleeding into the haze */
|
||||
}
|
||||
.ctx {
|
||||
z-index: 1;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// Mechanic 1 — FOCAL PULL. Blur scales with data-depth so far planes blur
|
||||
// more than near ones; the focal layer (--dof: 0, opacity: 1) is untouched.
|
||||
gsap.utils.toArray(".ctx").forEach((el) => {
|
||||
const depth = Number(el.dataset.depth) || 1;
|
||||
tl.to(
|
||||
el,
|
||||
{
|
||||
"--dof": `${BLUR_PER_DEPTH * depth}px`,
|
||||
opacity: DIM_LEVEL, // dim, not gone
|
||||
duration: FOCUS_DUR,
|
||||
ease: "power2.inOut",
|
||||
},
|
||||
FOCUS_START,
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Rack focus between two depth planes** — `gsap.set` plane B pre-blurred BEFORE the rack (no pop), then two tweens sharing `RACK_START` + `RACK_DUR`: A → `MAX_BLUR` + `DIM_LEVEL`, B → `0px` + `1`. Shared window makes them cross at the midpoint.
|
||||
- **Blur the cluster while pushing in** — run the focal-pull tweens at the same position + duration as a camera tween on `#world` (`scale/x/y`, `power2.inOut`). Camera transforms the world; DoF tweens the layers — independent property channels, no conflict.
|
||||
- **Spotlight a hero metric in a card grid** — `gsap.utils.toArray(".card:not(.hero)")` all defocus (`GRID_BLUR` + `DIM_LEVEL`) on one shared window; heroes are skipped.
|
||||
- **Refocus / settle** — if the beat resolves back to "everything visible" (or hands off to a crossfade needing a clean outgoing frame), ramp all `--dof` back to `0px` / opacity 1 over the tail (`REFOCUS_START + REFOCUS_DUR ≤ DURATION`).
|
||||
- **Bounded focus-breathing on the focal layer (optional)** — a finite `ease:"none"` driver writes `Math.max(0, Math.sin(p)) * FOCAL_BREATH_PX` into the focal `--dof` during a hold. Keep it ≤ ~0.6px or it reads as "still focusing"; default to omitting it.
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| --------------------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| BLUR_PER_DEPTH | 3–6 px per depth step | a 3-plane stack tops out ~9–18 px; low = gentle DoF, high = tilt-shift falloff |
|
||||
| MAX_BLUR | 8 soft → 16 default → 24 heavy px | terminal blur for a fully-defocused plane; above ~24 px on a big surface, shrink/group the layer instead |
|
||||
| GRID_BLUR | 6–12 px | pushes cards back without losing the grid's shape |
|
||||
| DIM_LEVEL | 0.4 strong → 0.55 default → 0.7 subtle | rarely below 0.35 — fully dark reads as "removed," not "defocused" |
|
||||
| FOCUS_DUR | 0.5–1.2 s | a rack/pull is a deliberate move, not a snap; shorter = snap focus, longer = languid |
|
||||
| RACK_START / RACK_DUR | shared by both planes | `gsap.set` the pre-blurred plane BEFORE `RACK_START` |
|
||||
| FOCAL_BREATH_PX | ≤ 0.6 px, period 2–3 s | barely-there nicety |
|
||||
| FOCAL vs CTX sizing | context smaller / grouped | small context layers let a modest radius still read as "out of focus" — and blur cheaply |
|
||||
|
||||
Tokens: dark `{bgGradient}` so the sharp focal layer reads as lit and forward; heavy display `{font}` weight — blurred copy needs it to stay shape-legible.
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Tween the `--dof` variable on the timeline** — reading `filter: blur(var(--dof))` keeps the blur on the HF seek clock.
|
||||
- **Blur the SMALL / GROUPED layers, not the giant one.** Filter cost scales with radius × pixel area; a 20 px blur on a full-frame background is the worst case. Keep per-layer radius ≤ ~24 px on large surfaces and lean on the `opacity` **dim** to do the push-back work — dim + modest blur reads more like real DoF than blur cranked to the max.
|
||||
- **`will-change: filter`** on every layer whose blur animates (drop it after settle if the layer also does heavy transform work).
|
||||
- **Focal layer stays genuinely sharp** — `--dof: 0`, untouched (or breathing ≤ 0.6 px). Any visible blur on the focal element kills the "this is the thing" read.
|
||||
- **State continuity on a rack** — the outgoing plane starts at the blur the incoming plane was holding, and vice-versa; adjacent tweens on the same `--dof` at the same position.
|
||||
- **DoF is independent of the camera** — blur the layers, transform `.world` for the push-in; don't fake DoF with the camera transform or vice-versa.
|
||||
- **Settle sharp before a hand-off** — refocus to `--dof: 0` in the tail if the next beat is a crossfade/push; handing off mid-defocus reads as "the render glitched."
|
||||
- **Sharp focal layer above blurred layers** (`z-index`).
|
||||
|
||||
## See also
|
||||
|
||||
[multi-phase-camera.md](multi-phase-camera.md) (the push-in this rule's falloff accompanies) · [coordinate-target-zoom.md](coordinate-target-zoom.md) (zoom onto the focal core — the `constellation-hub` hook) · [viewport-change.md](viewport-change.md) (pan + rack across a tilted card plane) · [counting-dynamic-scale.md](counting-dynamic-scale.md) (hero metric counts up sharp — the `dataviz-countup` spotlight) · [3d-page-scroll.md](3d-page-scroll.md) (the parallax stack to rack between) · [sine-wave-loop.md](sine-wave-loop.md) (post-rack idle; keep both amplitudes tiny).
|
||||
@@ -0,0 +1,139 @@
|
||||
---
|
||||
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, logo fragments) fly in from a rotating 3D depth-cloud and lock into a flat layout — or the reverse. Each element has its OWN index-derived point in the cloud (translateZ depth + rotateX/Y tumble + x/y scatter). Distinct from `orbit-3d-entry` (flip-in then continuous orbit) and `center-outward-expansion` (flat burst from one shared center): here the resolve is a flat assembled layout.
|
||||
|
||||
## How It Works
|
||||
|
||||
Each element's flat target lives in `data-target-x/y`; its scattered state is pure trig on its index — golden-angle spread, stepped depth — so the cloud is byte-identical every render with no `Math.random`:
|
||||
|
||||
```js
|
||||
const GOLDEN = Math.PI * (3 - Math.sqrt(5)); // ~2.39943 rad — even spread, no clumping
|
||||
const a = i * GOLDEN;
|
||||
const scatterX = Math.cos(a) * RADIUS;
|
||||
const scatterY = Math.sin(a) * RADIUS;
|
||||
const scatterZ = Z_NEAR - (i / (n - 1)) * (Z_NEAR - Z_FAR); // stepped depth
|
||||
const rotX = Math.sin(a) * TUMBLE;
|
||||
const rotY = Math.cos(a) * TUMBLE;
|
||||
```
|
||||
|
||||
Elements are PARKED at their scatter points (`gsap.set`, opacity 0) before any tween, then each tweens to its flat target while the whole stage slowly rotates so the scatter has life before it locks. Requires `perspective` on the scene root and `preserve-3d` on the stage AND each element, or depth + tumble flatten to a 2D scale.
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<!-- inside a standard scene clip (hyperframes-core) -->
|
||||
<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>
|
||||
<!-- … one .frag per glyph / fragment … -->
|
||||
</div>
|
||||
```
|
||||
|
||||
```css
|
||||
.scene-root {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
perspective: 1400px; /* REQUIRED */
|
||||
}
|
||||
.cloud-stage {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transform-style: preserve-3d;
|
||||
will-change: transform;
|
||||
}
|
||||
.frag {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform-style: preserve-3d;
|
||||
backface-visibility: hidden; /* hides the mirrored face mid-tumble */
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
const frags = Array.from(document.querySelectorAll(".frag"));
|
||||
const n = frags.length;
|
||||
const GOLDEN = Math.PI * (3 - Math.sqrt(5));
|
||||
|
||||
// 1) Park every fragment in the cloud BEFORE any tween fires
|
||||
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,
|
||||
};
|
||||
});
|
||||
frags.forEach((el, i) => gsap.set(el, { xPercent: -50, yPercent: -50, ...scatter[i], opacity: 0 }));
|
||||
|
||||
// 2) The cloud rotates so the scatter has life during assembly
|
||||
tl.to(
|
||||
".cloud-stage",
|
||||
{ rotationY: CLOUD_SPIN_DEG, duration: CLOUD_SPIN_DUR, ease: "power1.out" },
|
||||
0,
|
||||
);
|
||||
|
||||
// 3) ASSEMBLE — cloud point → flat target, index stagger = cloud collapsing inward
|
||||
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,
|
||||
},
|
||||
i * STAGGER,
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Tumble-swap** (the beat-change hand-off): two glyph sets share the cloud; ONE shared 0→1 progress tween drives both in its `onUpdate` — outgoing lerps layout→cloud with `opacity: 1−p`, incoming lerps cloud→layout with `opacity: p`. Two separate tweens drift out of phase under seek and the cross stops reading as one hand-off. Inject per-glyph spans per phrase at setup (measure advance widths after `document.fonts.ready` — single-scene only).
|
||||
- **Radial letter-explode → resolve**: flat-plane special case — `Z_NEAR = Z_FAR = 0`, small `TUMBLE`; reverse the assemble for the explode. Pure in-plane.
|
||||
- **Scatter-OUT**: reverse assemble (layout → cloud, opacity 1→0) ONLY as the composition's final beat — mid-shot it reads as the shot ending.
|
||||
- **Parallax lockup**: back layers get deeper `|Z_FAR|` + longer `ASSEMBLE_DUR`, foreground shallower/shorter — depth-speeded slide-in that locks into the logo.
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| ---------------------- | --------------------- | ----------------------------------------------------------------------------- |
|
||||
| n | 4–14 (fragments 4–9) | above ~14 individual paths stop reading |
|
||||
| RADIUS | 250–700px | keep the farthest scatter in frame or fragments pop in with no travel |
|
||||
| Z_NEAR / Z_FAR | +150…+450 / −150…−500 | large `\|z\|` needs a wider `perspective` or fragments smear |
|
||||
| TUMBLE | 40–110° | past 90° glyphs show blank mid-tween (intended); cap ~80° for one-faced cards |
|
||||
| ASSEMBLE_DUR | 0.7–1.4s | |
|
||||
| ASSEMBLE_EASE | `power3.out` default | `expo.out` snaps, `back.out(1.4)` seats with overshoot; never `in` |
|
||||
| STAGGER | 0.03–0.09s | `n × STAGGER < ASSEMBLE_DUR` — one collapsing motion, not a queue |
|
||||
| CLOUD_SPIN_DEG / \_DUR | 15–60° over ≥ dur | gentle life; too fast competes with the assembly |
|
||||
| SWAP_DUR | 0.5–1.0s | on the beat boundary; shorter = hard cross |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Every scattered value is index-derived** — `cos/sin(i × GOLDEN)` + stepped `z`. The golden angle spreads points evenly with no clumps and no `Math.random`.
|
||||
- **`gsap.set` the cloud BEFORE adding tweens** — skipping it leaves frame 0 showing the assembled layout, then a teleport when the first tween starts.
|
||||
- **`perspective` + `preserve-3d` on stage AND each fragment** — missing any one flattens the depth.
|
||||
- **Resolve flat** — settled state is `z: 0`, rotations 0; a still-tilted resolve reads unfinished.
|
||||
- **Tumble-swap: one shared progress for both glyph sets.**
|
||||
- **Depth ordering is automatic** inside `preserve-3d` (paint order follows actual Z) — no manual z-index, unlike the orbit case's capped band.
|
||||
|
||||
## See also
|
||||
|
||||
`orbit-3d-entry` (settles into a continuous orbit instead) · `hacker-flip-3d` (glyphs decode on arrival) · `3d-text-depth-layers` (extrude the locked wordmark) · `center-outward-expansion` (flat 2D cousin) · `sine-wave-loop` (idle breathe on the resolved layout).
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
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 — enabling non-linear effects (typos, backspaces, bulk paste, "thinking" gaps) that smooth per-char typing can't achieve. If your effect is "type each character, no edits", this rule is overkill — use the smooth-slice variation below.
|
||||
|
||||
## How It Works
|
||||
|
||||
The typing is authored as a sparse array of `{ t, text }` states; on every `onUpdate` a **reverse search** finds the latest entry whose `t` has passed and renders its text. Display jumps between states with no animation between them — the realism comes from the schedule shape: fast keystroke clusters (0.06–0.20s apart), pauses at word breaks (0.3–0.6s), a typo, backspaces peeling back to the fork, then a bulk paste replacing many chars in one entry. A block cursor blinks via a deterministic sin square wave on the same timeline.
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<!-- inside a standard scene clip (hyperframes-core) -->
|
||||
<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>
|
||||
```
|
||||
|
||||
```css
|
||||
.terminal {
|
||||
font-family: {monoFont}; /* monospace required — proportional jitters even in a fixed box */
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
font-size: TERMINAL_FONT_SIZE;
|
||||
}
|
||||
.text-wrap {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
min-width: TEXT_WRAP_MIN_WIDTH; /* ≥ widest state — stops right-edge jitter */
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cursor {
|
||||
display: inline-block; /* inline ignores width */
|
||||
width: CURSOR_WIDTH;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// Each entry shows from its t until the NEXT entry's t.
|
||||
// Shape: keystrokes → typo → backspace to the fork → bulk paste → 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 — many chars in one jump
|
||||
{ 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 driver = { t: 0 };
|
||||
tl.to(
|
||||
driver,
|
||||
{
|
||||
t: TOTAL_DURATION,
|
||||
duration: TOTAL_DURATION,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
textEl.textContent = textAt(driver.t);
|
||||
},
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
// Cursor blink — deterministic sin square wave, never a CSS animation
|
||||
const blink = { p: 0 };
|
||||
tl.to(
|
||||
blink,
|
||||
{
|
||||
p: Math.PI * 2 * BLINK_CYCLES,
|
||||
duration: TOTAL_DURATION,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
cursorEl.style.opacity = Math.sin(blink.p) > 0 ? "1" : "0";
|
||||
},
|
||||
},
|
||||
0,
|
||||
);
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Smooth character slice** (continuous typewriter — no pauses, no edits): faster to author but uniformly "machine-typed", missing the human realism:
|
||||
|
||||
```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,
|
||||
);
|
||||
```
|
||||
|
||||
- **Thinking pause** — hold one state for `THINK_HOLD_DUR` (0.8–2.0s; under 0.5s reads as a stutter, not thought) simply by leaving a gap before the next entry's `t`.
|
||||
- **State pulse on completion** — when the final state lands, `tl.to(".text", { scale: 1.03–1.08, duration: 0.15–0.3, yoyo: true, repeat: 1 }, T_DONE)`.
|
||||
- **Per-state color shift** — in `onUpdate`, branch on `driver.t` vs the milestones: success color after `T_DONE`, dim mid-edit, normal while typing.
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| ------------------- | -------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| TERMINAL_FONT_SIZE | 48–96px | full-bleed comps; smaller for terminal-style detail |
|
||||
| TEXT_WRAP_MIN_WIDTH | ≥ widest state | measure with a hidden probe after `document.fonts.ready` if unsure |
|
||||
| milestone `t`s | keystrokes 0.06–0.20s apart; pauses 0.3–0.6s | monotonically increasing; `T_DONE ≤ TOTAL_DURATION − ~1s` climax dwell |
|
||||
| TYPE_DUR (smooth) | `chars × 0.06–0.12s` | fast → relaxed |
|
||||
| BLINK_CYCLES | one cycle per 0.5–0.8s | `TOTAL_DURATION / 0.8 ≤ BLINK_CYCLES ≤ TOTAL_DURATION / 0.5` |
|
||||
| CURSOR_WIDTH | ~0.3× font size | gap to text single-digit px so the cursor feels attached |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Reverse-search the array each frame** — O(n) with small n (≤30 typical); don't index by frame, the sequence is sparse.
|
||||
- **`min-width` on the text wrap is mandatory** — without it the right edge jitters as state length changes.
|
||||
- **Discrete jumps must be INSTANT** — any transition on the text turns the jump into a smear and kills the "typing" feel.
|
||||
- **Cursor blink is sin/sequence-driven on the timeline**, `display: inline-block`, monospace font, `white-space: nowrap` (wrapping mid-state breaks the illusion; trailing spaces must survive).
|
||||
- **Discrete vs smooth** — use discrete only for non-linear states (typos, pauses, bulk paste); plain typing takes the smooth-slice variation.
|
||||
|
||||
## See also
|
||||
|
||||
`context-sensitive-cursor` (same SEQUENCE pattern + segment-colored cursor) · `3d-text-depth-layers` (discrete text with layered depth) · `counting-dynamic-scale` (discrete label beside a smooth counter) · `press-release-spring` (post-completion press beat).
|
||||
@@ -0,0 +1,149 @@
|
||||
---
|
||||
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 computed from its content length + per-item config, and the sequencer assigns absolute start/end times automatically — no hardcoded offsets per item. Distinct from [discrete-text-sequence](discrete-text-sequence.md) (one text element changing states) — this rule swaps between distinct content blocks.
|
||||
|
||||
## How It Works
|
||||
|
||||
A content array of `{ eyebrow, title, body, speedFactor, hold }` entries is reduced once at build time into a flat `TIMELINE` of `{ …entry, start, end }` — duration per entry is `BASE_DURATION + body.length × SEC_PER_CHAR + hold`, so longer text earns more reading time. A single linear driver's `onUpdate` reverse-searches the active entry and swaps the DOM **only on transitions** (a `lastTitle` guard — per-frame `textContent` writes flicker in render); an optional progress bar fills 0→100% across the whole run.
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<!-- inside a standard scene clip (hyperframes-core) -->
|
||||
<div class="display">
|
||||
<div class="eyebrow" id="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>
|
||||
```
|
||||
|
||||
```css
|
||||
.body {
|
||||
min-height: 160px; /* reserve space — content height varies; without this, layout jumps */
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// N entries, each with its own pacing (optionally a speedFactor multiplier);
|
||||
// the final entry uses a larger hold (closing beat).
|
||||
const CONTENT = [
|
||||
{ eyebrow: "{eyebrow1}", title: "{title1}", body: "{body1}", hold: HOLD_MID },
|
||||
// …
|
||||
{ eyebrow: "{eyebrowN}", title: "{titleN}", body: "{bodyN}", hold: HOLD_FINAL },
|
||||
];
|
||||
|
||||
// Pre-compute absolute start/end ONCE — never in onUpdate.
|
||||
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 };
|
||||
});
|
||||
|
||||
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);
|
||||
// Swap content only on transitions — no per-frame DOM thrash
|
||||
if (entry.title !== lastTitle) {
|
||||
eyebrowEl.textContent = entry.eyebrow;
|
||||
titleEl.textContent = entry.title;
|
||||
bodyEl.textContent = entry.body;
|
||||
lastTitle = entry.title;
|
||||
}
|
||||
progressEl.style.width = `${(driver.t / TOTAL_DURATION) * 100}%`;
|
||||
},
|
||||
},
|
||||
0,
|
||||
);
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Crossfade between items** — return BOTH adjacent entries during an overlap window (`time ≥ e.start − overlap && time ≤ e.end + overlap`, overlap ≈ 0.3s) and render them with opacities computed from distance to the boundary.
|
||||
- **Per-item motion variation** — map an `entry.style` key to an existing rule per chapter (e.g. `3d-text-depth-layers` → `hacker-flip-3d` → `counting-dynamic-scale`); the sequencer only orchestrates timing.
|
||||
- **Auto-extend composition duration** — you can set `data-duration` from the computed `TOTAL_DURATION` in script, but HF reads `data-duration` at composition load and setting it after init may not take effect — author the duration manually from a rough total.
|
||||
|
||||
### Accelerating cadence (geometric hold decay)
|
||||
|
||||
For rhetorical escalation — "everyone says…", a roll-call, a praise flurry — the beat grid itself accelerates: early entries hold ~1s (read speed), then windows shrink geometrically into a ~0.15–0.3s flurry, braking on an emphasis state before the resolve. The acceleration is pre-computed into the same flat `TIMELINE` — still content-driven, still deterministic, no speed-up tween anywhere:
|
||||
|
||||
```js
|
||||
// Geometric decay on the hold, clamped at a flurry floor; the brake state holds longest.
|
||||
const HOLDS = CONTENT.map((entry, i) => Math.max(FLURRY_FLOOR, HOLD_START * Math.pow(DECAY, i)));
|
||||
HOLDS[CONTENT.length - 1] = HOLD_FINAL;
|
||||
|
||||
let cumulative = 0;
|
||||
const TIMELINE = CONTENT.map((entry, i) => {
|
||||
// Past ~0.5s states are glanced as motion texture, not read —
|
||||
// drop the per-char term or you never reach flurry speed.
|
||||
const readable = HOLDS[i] >= READ_THRESHOLD;
|
||||
const dur = HOLDS[i] + (readable ? entry.body.length * SEC_PER_CHAR : 0);
|
||||
const start = cumulative;
|
||||
cumulative += dur;
|
||||
return { ...entry, start, end: cumulative };
|
||||
});
|
||||
```
|
||||
|
||||
Worked example — **praise-chip flurry**: ~16 short quotes hard-cut through a chip beside a pinned wordmark. First 3 states at `HOLD_START = 1.0` (each reads fully); `DECAY = 0.8` shrinks every following window until `FLURRY_FLOOR = 0.2` catches it (≈12 states over ~2.5s — a churn of acclaim, individually glanced); the longest phrase takes `HOLD_FINAL ≈ 1.6` as the brake before the closing lockup.
|
||||
|
||||
Values: `HOLD_START` 0.8–1.2s; `DECAY` 0.75–0.88 (higher = longer runway before the flurry bites); `FLURRY_FLOOR` 0.15–0.3s (below ~0.15s swaps strobe); `READ_THRESHOLD` ~0.5s; brake ≥ 4× the floor or the stop doesn't register as a beat. The 3–6 entry guidance relaxes here — 12–18 states are legal precisely because flurry states aren't individually read. The hard-cut discipline (`lastTitle` guard, instant swaps) is what lets 0.2s states render clean.
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| ------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| BASE_DURATION | 0.6–1.5s | minimum per entry regardless of length — even one-word entries get read time |
|
||||
| SEC_PER_CHAR | 0.03–0.06 s/char | ≈17–33 chars/sec; uniform across the sequence so the pace reads as one engine; lean high for wide-character languages |
|
||||
| HOLD_MID | 0.5–1.0s | dwell on a non-final entry; `< HOLD_FINAL` |
|
||||
| HOLD_FINAL | 1.0–2.0s | climax dwell — must exceed HOLD_MID by a clear margin so the close reads as a beat |
|
||||
| SPEED_FACTOR | 0.5–2.0 (default 1.0) | per-entry only; if every entry shares a factor, fold it into SEC_PER_CHAR |
|
||||
| TAIL_PAD | 0.0–1.0s | quiet beat after the last entry; prefer 0 when the next composition owns the breath |
|
||||
| CONTENT N | 3–6 entries | <3 isn't a sequence; >6 drags (accelerating cadence relaxes this — see above) |
|
||||
|
||||
Reference: `../../examples/messaging-multi-phrase.html`.
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Pre-compute the TIMELINE once at build** — never recompute in `onUpdate`; the reverse search over the flat array is the whole per-frame cost.
|
||||
- **DOM swap only on entry transition** (`lastTitle`/key guard) — per-frame `textContent` assignment flickers in HF render.
|
||||
- **`min-height` on the body element** — without reservation, downstream elements (progress bar, brand) jitter as content height varies.
|
||||
- **Sequential only** — for parallel tracks use a different reduction.
|
||||
- **Titles fit one line at the chosen size; bodies fit inside `min-height` after wrapping.**
|
||||
|
||||
## See also
|
||||
|
||||
`discrete-text-sequence` (per-entry typewriter on the body) · `context-sensitive-cursor` (cursor color per chapter) · `vertical-spring-ticker` (animated word swap instead of hard cut) · `scale-swap-transition` (visual morph between entries).
|
||||
@@ -0,0 +1,136 @@
|
||||
---
|
||||
name: gradient-text-sweep
|
||||
description: A gradient tweened THROUGH letterforms — background-clip:text + a backgroundPosition tween. Three forms: a continuous horizontal sweep inside a held headline, a traveling word-to-word highlight, and a hue-sweep that settles to a solid. Glyphs never move; finite, deterministic, seek-safe.
|
||||
metadata:
|
||||
tags: gradient, text, sweep, background-clip, highlight, hue, typography, headline
|
||||
---
|
||||
|
||||
# Gradient Text Sweep
|
||||
|
||||
Color that lives **inside the glyphs**: the headline's fill is an oversized gradient clipped into the letterforms (`background-clip: text`), and the motion is the gradient sliding **through** the type — the letters never move. Three forms: a **continuous sweep** across a held title card, a **word-to-word highlight** that lights a line left→right, and a **hue-sweep** that settles to a solid.
|
||||
|
||||
Boundaries: [asr-keyword-glow.md](asr-keyword-glow.md) is word-timed emphasis railed to ASR timestamps — this rule is a design beat with no audio rail. [ambient-glow-bloom.md](ambient-glow-bloom.md)'s traveling sweep is a sheen riding **over a surface**; here the gradient is masked **into the type** (its "Shimmer sweep" variation is this mechanism re-aimed as a working-state loop). [css-marker-patterns.md](css-marker-patterns.md) draws accents _around_ text, never fills.
|
||||
|
||||
## How It Works
|
||||
|
||||
The text carries a gradient background **wider than its own box** (`background-size: SWEEP_SPAN 100%`, e.g. `300% 100%`) clipped into the glyphs, so tweening `backgroundPosition` slides the gradient through the visible letterforms. Two gotchas own this rule:
|
||||
|
||||
- **`background-position` percentages only produce travel when `background-size` exceeds 100%** — at 100% the image is pinned and the tween is a silent no-op.
|
||||
- **The percent axis runs opposite to the perceived travel** — tweening `"100% 50%"` → `"0% 50%"` moves the highlight left→right through the text.
|
||||
|
||||
1. **Continuous sweep (held title card)** — one long **linear** `backgroundPosition` tween spanning the hold. First and last color stops equal, so the travel has no visible seam and reads as endless while remaining a single finite tween.
|
||||
2. **Word-to-word highlight** — each word is two pixel-identical stacked copies: a base copy in the resting color and a gradient-clipped copy at `opacity: 0`. A per-word opacity envelope (rise, then fall as the next word rises) passes the highlight along on an index-derived stagger — an **envelope, not a moving mask**: no per-word position measurement.
|
||||
3. **Hue-sweep → solid** — the gradient holds position while a `filter: hue-rotate()` tween sweeps its hues; the settle is a stacked-copy crossfade to a solid twin — never a color-stop tween (gradients with different stops don't interpolate reliably).
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<!-- inside a standard scene clip (hyperframes-core) -->
|
||||
<!-- Forms A/C: gradient headline; solid twin behind for the Form C settle -->
|
||||
<div class="headline-stack">
|
||||
<h1 class="headline solid-twin">{headlineText}</h1>
|
||||
<h1 class="headline gradient-fill" id="headline">{headlineText}</h1>
|
||||
</div>
|
||||
|
||||
<!-- Form B: per-word stacked copies -->
|
||||
<p class="line">
|
||||
<span class="word"><span class="w-base">{word1}</span><span class="w-hot">{word1}</span></span>
|
||||
<span class="word"><span class="w-base">{word2}</span><span class="w-hot">{word2}</span></span>
|
||||
</p>
|
||||
```
|
||||
|
||||
```css
|
||||
.headline-stack,
|
||||
.word {
|
||||
display: grid; /* twins share one cell — pixel-identical boxes */
|
||||
}
|
||||
.headline,
|
||||
.w-base,
|
||||
.w-hot {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
.gradient-fill,
|
||||
.w-hot {
|
||||
background-image: {gradient}; /* {sweepGradient} A/C, {highlightGradient} B */
|
||||
background-size: SWEEP_SPAN 100%; /* MUST exceed 100% or the position tween is dead */
|
||||
background-position: 100% 50%; /* start; tween toward 0% for left→right travel */
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.solid-twin {
|
||||
color: {settleColor};
|
||||
}
|
||||
.w-base {
|
||||
color: {restColor};
|
||||
}
|
||||
.w-hot {
|
||||
opacity: 0; /* the envelope raises it as the highlight passes */
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// Form A: continuous sweep. 100% → 0% reads left→right (percent axis inverted);
|
||||
// ease "none" — an eased sweep reads as an object, not light.
|
||||
tl.fromTo(
|
||||
"#headline",
|
||||
{ backgroundPosition: "100% 50%" },
|
||||
{ backgroundPosition: "0% 50%", duration: SWEEP_DUR, ease: "none" },
|
||||
SWEEP_START,
|
||||
);
|
||||
|
||||
// Form B: traveling highlight — per-word rise/fall envelopes, index stagger.
|
||||
gsap.utils.toArray(".w-hot").forEach((el, i) => {
|
||||
const at = HIGHLIGHT_START + i * WORD_LAG;
|
||||
tl.fromTo(el, { opacity: 0 }, { opacity: 1, duration: HOT_RISE, ease: "power2.out" }, at);
|
||||
tl.to(el, { opacity: 0, duration: HOT_FALL, ease: "power2.in" }, at + WORD_LAG);
|
||||
});
|
||||
|
||||
// Form C: hue-sweep, then crossfade to the solid twin (never tween color stops).
|
||||
tl.fromTo(
|
||||
"#headline",
|
||||
{ filter: "hue-rotate(0deg)" },
|
||||
{ filter: `hue-rotate(${HUE_RANGE}deg)`, duration: HUE_DUR, ease: "power1.inOut" },
|
||||
HUE_START,
|
||||
);
|
||||
tl.to(
|
||||
"#headline",
|
||||
{ opacity: 0, duration: SETTLE_SNAP_DUR, ease: "power2.in" },
|
||||
HUE_START + HUE_DUR,
|
||||
);
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Title-card crawl** — Form A stretched across a long terminal hold (3–8s end card): seamless-ended gradient, `ease: "none"`, `SWEEP_DUR` = the whole hold. One tween, no loop.
|
||||
- **One-pass sheen inside type** — gradient is the resting fill everywhere except one narrow highlight band (≤ ~25% of the span); one `backgroundPosition` pass carries the band through and the text returns to rest with no crossfade.
|
||||
- **Karaoke settle** — Form B with the fall tweens skipped: the line lights cumulatively left→right and holds fully lit; settle color = the hot state, base copies start dimmer.
|
||||
- **Gradient climax word** — one emphasized word (often ~-8° rotated) carries the gradient while the line stays solid; static gradient + a short Form C hue shift on landing, settling to the brand accent. Pairs with a `kinetic-beat-slam` arrival.
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| ------------------- | ---------------------- | ------------------------------------------------------------------------------------ |
|
||||
| SWEEP_SPAN | 200–400% | must exceed 100%; wider = softer/slower feel, narrower = busier color per glyph |
|
||||
| SWEEP_DUR | 1.2–3s | match the card's hold exactly; slower than ~4s stops registering as motion |
|
||||
| WORD_LAG | 0.25–0.5s | HOT_FALL starts exactly WORD_LAG after the rise so envelopes cross — a gap = a blink |
|
||||
| HOT_RISE / HOT_FALL | 0.15–0.3s / 0.25–0.45s | fall slightly longer — the highlight "trails" |
|
||||
| HUE_RANGE / HUE_DUR | 40–180° / 0.8–1.6s | past ~180° the palette dissociates from itself mid-sweep |
|
||||
| SETTLE_SNAP_DUR | 0.1–0.35s | the goldens snap (~0.15s) |
|
||||
| {settleColor} | — | one of the gradient's own stops (or the brand ink) so the settle reads as resolution |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **`background-size` > 100%** on any element whose `backgroundPosition` is tweened — otherwise the tween is a silent no-op.
|
||||
- **Percent axis is inverted** — left→right perceived travel is `100% → 0%`.
|
||||
- **Both `-webkit-background-clip: text` AND `background-clip: text`, with `color: transparent`** — missing the prefix renders a solid gradient block over the text in the capture browser.
|
||||
- **`ease: "none"` on position sweeps** — this is supposed to read as light, not an accelerating object.
|
||||
- **Seamless ends for a crawl** — first and last stops equal, or the wrap point flashes a hard edge mid-hold.
|
||||
- **Stacked copies pixel-identical** — same box, font, weight, tracking, one grid cell; any metric drift makes the crossfade a double-exposure.
|
||||
- **`data-layout-allow-occlusion` on the twin** — pixel-identical stacked copies trip `hyperframes check`'s `text_occluded` gate by construction; the flag is the sanctioned waiver for this mechanism.
|
||||
- **Settle by crossfade, never by tweening stops**; and the glyphs never move — if the type must travel, that's a separate rule on the wrapper.
|
||||
- **No CSS `@keyframes` shimmer** — wall-clock animation desyncs from seek; every sweep is a timeline tween.
|
||||
|
||||
## See also
|
||||
|
||||
`kinetic-beat-slam` (slam lands the climax word, hue settle finishes it) · `spring-pop-entrance` (pop in solid, sweep after) · `discrete-text-sequence` (swap-slot under a riding crawl) · `ambient-glow-bloom` (surface-level sibling) · `css-marker-patterns` (strokes around text; fills here).
|
||||
@@ -0,0 +1,196 @@
|
||||
# GSAP Effects for HyperFrames
|
||||
|
||||
Drop-in animation patterns. Snippets show mechanism only, inside a standard scene clip (hyperframes-core); assume `tl` exists.
|
||||
|
||||
- [Typewriter](#typewriter) — character-by-character reveal with optional cursor / backspace / word rotation
|
||||
- [Audio Visualizer](#audio-visualizer) — pre-extract audio data, drive Canvas/DOM rendering from the timeline
|
||||
|
||||
## Typewriter
|
||||
|
||||
Requires GSAP's TextPlugin alongside the core script:
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/TextPlugin.min.js"></script>
|
||||
<script>
|
||||
gsap.registerPlugin(TextPlugin);
|
||||
</script>
|
||||
```
|
||||
|
||||
### Basic
|
||||
|
||||
```js
|
||||
const text = "Hello, world!";
|
||||
const cps = 10; // chars per second — see timing table
|
||||
tl.to(
|
||||
"#typed-text",
|
||||
{ text: { value: text }, duration: text.length / cps, ease: "none" },
|
||||
startTime,
|
||||
);
|
||||
```
|
||||
|
||||
### Blinking Cursor
|
||||
|
||||
Three rules: **one cursor visible at a time** (hide previous before showing next); **cursor must blink when idle** (after typing, during holds); **no gap between text and cursor** (elements 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 → 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);
|
||||
```
|
||||
|
||||
Multi-line handoff: hide previous cursor → blink new → brief pause (~0.5s) → solid when typing. Never go `hidden → solid` (skips the idle blink).
|
||||
|
||||
### Backspacing
|
||||
|
||||
TextPlugin removes from the 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
|
||||
|
||||
A typewriter word next to static text (`<span>Ship something</span><span style="margin-left:14px"><span id="word"></span><span id="cursor">|</span></span>` in a baseline-aligned flex row): use `margin-left` on the 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).
|
||||
|
||||
### Word Rotation
|
||||
|
||||
Type → hold → backspace → next word; cursor blinks during every idle moment:
|
||||
|
||||
```js
|
||||
let offset = 0;
|
||||
words.forEach((word, i) => {
|
||||
const typeDur = word.length / 10;
|
||||
// cursor: solid while typing, blink during holds (same call pattern as above)
|
||||
tl.to("#typed-text", { text: { value: word }, duration: typeDur, ease: "none" }, offset);
|
||||
offset += typeDur + 1.5; // hold
|
||||
if (i < words.length - 1) offset += backspace(tl, "#typed-text", word, offset, 20) + 0.3;
|
||||
});
|
||||
```
|
||||
|
||||
### Appending Words
|
||||
|
||||
Build a sentence word-by-word into the same element: keep an `accumulated` string, each step tweens `text: { value: accumulated + " " + word }` with `duration: newChars / cps`, then advances the offset.
|
||||
|
||||
### 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 the timeline. **Do not use the Web Audio API at render time** — there's no playback during seek.
|
||||
|
||||
### Extract Audio Data
|
||||
|
||||
Bundled extractor (requires `ffmpeg` + 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
|
||||
```
|
||||
|
||||
Output: `{ "fps": 30, "totalFrames": 5415, "frames": [{ "time": 0.0, "rms": 0.42, "bands": [0.8, 0.6, 0.3] }] }` — `rms` (0-1) is overall loudness; `bands[]` (0-1) are frequency magnitudes, index 0 = bass, each band normalized independently.
|
||||
|
||||
### Loading (Synchronously)
|
||||
|
||||
Inline the JSON for small files (< ~500 KB), or sync XHR for large ones:
|
||||
|
||||
```js
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("GET", "audio-data.json", false); // synchronous — deliberate
|
||||
xhr.send();
|
||||
const 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 it isn't ready when capture starts.
|
||||
|
||||
### Driving the Timeline
|
||||
|
||||
Canvas 2D is the workhorse (bars, waveforms, circles, gradients) — one `tl.call` per frame:
|
||||
|
||||
```js
|
||||
const ctx = document.getElementById("viz").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 under ~20 elements, slower than Canvas beyond that.
|
||||
|
||||
### 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] };
|
||||
else {
|
||||
prev = {
|
||||
rms: prev.rms * smoothing + raw.rms * (1 - smoothing),
|
||||
bands: raw.bands.map((b, i) => prev.bands[i] * smoothing + b * (1 - smoothing)),
|
||||
};
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
```
|
||||
|
||||
### Design Guide
|
||||
|
||||
- **Spatial mapping** — horizontal: bass left, treble right; vertical: bass bottom; circular: bass at 12 o'clock, wrap clockwise (mirror for a full circle).
|
||||
- **Bass drives big moves** (scale, glow, position); **treble drives detail** (shimmer, flicker, edges); **RMS drives globals** (background brightness, overall energy).
|
||||
- Pick 2-3 animated properties — more looks noisy. Keep minimums above zero so quiet sections still have life.
|
||||
- **Band count**: 4 = background glow/pulse, 8 = bar charts, 16 = detailed EQ (default), 32 = dense radial layouts.
|
||||
- **Layering**: stack canvases with `z-index` — a background layer driven by bass/rms under a foreground layer driven by individual bands gives depth without per-element complexity.
|
||||
@@ -0,0 +1,123 @@
|
||||
---
|
||||
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 pseudo-random glyphs, then settle on the target character — a "decryption" / airport flap-display reveal. Resolves to a short target word (typically a brand or label).
|
||||
|
||||
## How It Works
|
||||
|
||||
Each character gets its own per-char tween from `rotateX: 90deg` (hidden, hinged at the bottom edge) to `0deg` (upright), staggered across the word. Below `REVEAL_THRESHOLD` progress the char displays a seeded pseudo-random glyph that reshuffles every few frames; past it, the real target character clicks into place — so the eye catches the right letter just as the flip settles. A hidden ghost copy of the full word reserves layout width so narrow flicker glyphs never shift the line.
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<!-- inside a standard scene clip (hyperframes-core) -->
|
||||
<div class="hacker-text-wrap" id="hacker-text" data-target="{phrase}">
|
||||
<!-- ghost row + per-char spans injected by the setup script -->
|
||||
</div>
|
||||
```
|
||||
|
||||
```css
|
||||
/* the scene root (or nearest 3D ancestor) MUST set perspective: 1500px */
|
||||
.hacker-text-wrap {
|
||||
font-family: {monoFont}; /* monospace so flicker glyphs hold width */
|
||||
font-weight: 900;
|
||||
font-size: HACKER_FONT_SIZE;
|
||||
position: relative; /* ghost stacks absolutely behind the live row */
|
||||
}
|
||||
.hacker-char {
|
||||
display: inline-block;
|
||||
transform-origin: bottom; /* flap-display hinge */
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
.hacker-ghost {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
const wrap = document.getElementById("hacker-text");
|
||||
const targetWord = wrap.dataset.target;
|
||||
const GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%&*";
|
||||
|
||||
// Ghost row (reserves width) + live per-char spans
|
||||
const ghost = document.createElement("div");
|
||||
ghost.className = "hacker-ghost";
|
||||
ghost.textContent = targetWord;
|
||||
wrap.appendChild(ghost);
|
||||
const charEls = [...targetWord].map((ch) => {
|
||||
const span = document.createElement("span");
|
||||
span.className = "hacker-char";
|
||||
span.textContent = ch === " " ? " " : ch;
|
||||
span.dataset.target = ch;
|
||||
wrap.appendChild(span);
|
||||
return span;
|
||||
});
|
||||
|
||||
// Index-seeded hash — same frame always yields the same glyph
|
||||
function pseudoGlyph(seed) {
|
||||
const h = ((seed * 9301 + 49297) % 233280) / 233280;
|
||||
return GLYPHS[Math.floor(h * GLYPHS.length)];
|
||||
}
|
||||
|
||||
charEls.forEach((el, i) => {
|
||||
const state = { p: 0 };
|
||||
tl.to(
|
||||
state,
|
||||
{
|
||||
p: 1,
|
||||
duration: FLIP_DURATION,
|
||||
ease: "power3.out",
|
||||
onUpdate: () => {
|
||||
if (state.p < REVEAL_THRESHOLD) {
|
||||
el.textContent = pseudoGlyph(i * 1000 + Math.floor(state.p * 100));
|
||||
} else {
|
||||
el.textContent = el.dataset.target === " " ? " " : el.dataset.target;
|
||||
}
|
||||
el.style.transform = `rotateX(${90 - state.p * 90}deg)`;
|
||||
el.style.opacity = Math.min(1, state.p * 2);
|
||||
},
|
||||
},
|
||||
i * CHAR_STAGGER,
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Top-down hinge** — `transform-origin: 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 (symbols → letters → real) for a longer reveal.
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| ---------------- | ------------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| HACKER_FONT_SIZE | 6–10% of viewport min-dimension | the flip IS the focal beat; ghost must use the identical size |
|
||||
| FLIP_DURATION | 0.4–1.0s | under 0.4s the flicker phase has no time; over 1.0s drags |
|
||||
| CHAR_STAGGER | 0.03–0.08s | total decode = `CHAR_STAGGER × (chars − 1) + FLIP_DURATION` — fit the phase budget |
|
||||
| REVEAL_THRESHOLD | 0.5–0.7 | lower reveals too early (no tension); higher reads as a hard end-reveal |
|
||||
| FLICKER_RATE | 3–6 frames per glyph swap | <3 looks like noise; >6 looks like discrete typing |
|
||||
|
||||
Reference: `../../examples/proof-logo-chain.html` (163px, 0.55s, 0.033s, 0.6).
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **`perspective` on the scene root REQUIRED** — without parent perspective, `rotateX` renders as a 2D squash, not a 3D flip; `transform-style: preserve-3d` on each char.
|
||||
- **Ghost placeholder** with identical content + font must back the live chars — without it, narrow glyphs shift the layout mid-flicker (monospace preferred; the ghost makes a proportional face recoverable).
|
||||
- **Flicker seed = char index + quantized progress** — the same frame must show the same glyph.
|
||||
- **Flicker rate ≥ ~3 frames per swap**; `onUpdate` work stays O(1) per char per frame.
|
||||
- **Center the flip dead-center and add NO decorative chrome** (timestamp lines, "// AUTH" tags, status dots) — the flip is the beat. A necessary secondary label is BIG typography (56–72px caps + tracking) in the same stack, never a tiny corner annotation.
|
||||
|
||||
## See also
|
||||
|
||||
`card-morph-anchor` (flip reveals a phrase, card morphs into the next shot) · `counting-dynamic-scale` (the numeric counterpart).
|
||||
@@ -0,0 +1,137 @@
|
||||
---
|
||||
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 — 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
|
||||
|
||||
A single tempo grid — `PULSE` seconds per sub-beat, `BEATS = [t0, t1, t2, …]` on that grid — is the rhythmic spine; every phrase entrance, accent, and chrome tick reads its time from it, so the piece locks to one pulse instead of drifting hand-tuned offsets. Each phrase gets a different transform axis (scale+blur slam / side snap / rise+rotate) with short attacks (0.35–0.6s on the hit), then the stack holds with a finite low-amplitude breath.
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<!-- inside a standard scene clip (hyperframes-core) -->
|
||||
<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>
|
||||
```
|
||||
|
||||
```css
|
||||
.kbs-stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 120px 160px; /* title-safe margin */
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.kbs-line .verb {
|
||||
color: #ff5b2e; /* exactly 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;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// ONE tempo grid drives everything — phrases AND the metronome read it.
|
||||
const PULSE = 0.4; // seconds per sub-beat
|
||||
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 tick flashes on the SAME grid, not a magic offset.
|
||||
gsap.utils.toArray(".kbs-metronome i").forEach((tick, i) => {
|
||||
tl.to(tick, { opacity: 1, duration: 0.08, yoyo: true, repeat: 1, ease: "none" }, PULSE * (i + 1));
|
||||
});
|
||||
|
||||
// Finale hold: floor (not ceil) so the repeat never overshoots data-duration;
|
||||
// max(0,…) so a short hold never yields a negative repeat (GSAP reads negative as -1 = infinite).
|
||||
const holdStart = BEATS[2] + 0.7,
|
||||
cycle = 1.6,
|
||||
holdDur = SCENE_DURATION - 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,
|
||||
);
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Entrance easing by attack character** — `power4.out` hard slam ⭐ default hit · `expo.out` hardest snap (side-snaps, whip-ins) · `back.out(2)` overshoot pop (accents only, not body words) · `circ.out` heavy rise with momentum. Use **at least 3 distinct easings** across the piece.
|
||||
- **Rhythm chrome alternatives** — a center beat bar or a `// label` monospace tag pulsing on-beat instead of the 5-tick metronome; mark any decorative that must survive a shader transition per `../../transitions/overview.md`.
|
||||
- **Finale dressing** — stack + accent underline sweep ([css-marker-patterns](css-marker-patterns.md)); don't just leave the last phrase sitting.
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| ----------------- | -------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| BEATS spacing | 1.2–1.8s | <0.8s frantic, >2.5s loses the pulse; keep spacing even — it's a beat |
|
||||
| entrance duration | 0.35–0.6s | the hit must resolve before the next beat; exits ≤0.25s |
|
||||
| accent hue | exactly 1 | the verbs; the rest mono white / near-black |
|
||||
| display face | 150px+, heavy weight | Archivo Black / League Gothic / Oswald — see `hyperframes-creative/references/typography.md` |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **One beat array, not scattered offsets** — every element times off `BEATS[]` / `PULSE`; this is the single biggest lever for "rhythmic".
|
||||
- **Different entrance per phrase** — a reused `punchIn()` for all lines is the flat-but-competent tell. Vary the motion axis, reuse the ease _family_.
|
||||
- **Finale repeat math**: `repeat: Math.max(0, Math.floor(dur / cycle) - 1)` — `Math.ceil` overshoots `data-duration` and trips the `gsap_repeat_ceil_overshoot` lint rule; a negative repeat is read by GSAP as `-1` (infinite).
|
||||
- **No banned exit animations between scenes** — in a montage the _transition_ is the exit (`../../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`).
|
||||
|
||||
## See also
|
||||
|
||||
`3d-text-depth-layers` (extruded depth on the slammed words) · `css-marker-patterns` (finale underline/circle) · `sine-wave-loop` (the finale breath) · `../adapters/gsap-easing-and-stagger.md` (easing vocabulary).
|
||||
@@ -0,0 +1,130 @@
|
||||
---
|
||||
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 motion blur isn't available to a seeked renderer (it integrates over shutter time), so this rule **fakes** it for a fast fly-in or hard camera push-through. 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 paths:
|
||||
|
||||
- **(A) Directional SVG blur** — inline `<feGaussianBlur stdDeviation="X 0">` (X on the motion axis, 0 across it), tweened via a proxy. Cleanest; a true directional smear.
|
||||
- **(B) Echo / ghost trail** — 2–4 duplicates at decreasing opacity, offset backward along the motion vector, collapsing into the lead as it settles. No filter cost; a stylized "speed-line" trail.
|
||||
|
||||
**Entrances and mid-shot moves only — never a mid-composition exit.** A blurred element fleeing off-frame mid-composition reads as a glitch; a hard exit between scenes is the transition's job (`../../transitions/overview.md`). One sanctioned scope extension: the envelope may ride the **camera wrapper** during a travel leg — see the Camera-Travel Carve-Out.
|
||||
|
||||
## How It Works
|
||||
|
||||
A fast `out`-eased move front-loads velocity — fastest off the start, bleeding to zero at the settle. Map the blur/echo envelope onto that same curve: position travels from an off-frame / pushed-back start to rest over `MOVE_DUR`; in lockstep on the same window and ease the smear goes `PEAK_BLUR → 0` (A) or the ghosts collapse onto the lead (B). By the settle the element is fully crisp and dwells ≥1 s — the contrast between violent streak and still, sharp settle IS the effect. GSAP can't tween an SVG attribute directly: tween a plain `{ v }` proxy and write `setAttribute("stdDeviation", …)` in `onUpdate`, seeding it once at setup so a seek to t=0 shows the streaked start.
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<!-- inside a standard scene clip; overflow: hidden on the scene (the smear extends past rest) -->
|
||||
<svg width="0" height="0" aria-hidden="true" style="position: absolute">
|
||||
<filter id="streak" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur id="streak-blur" in="SourceGraphic" stdDeviation="0 0" />
|
||||
</filter>
|
||||
</svg>
|
||||
<div class="streak-el" id="streak-el" style="filter: url(#streak)">{phrase}</div>
|
||||
<!-- Path B instead: N-1 aria-hidden .streak-ghost duplicates BEHIND the lead, no filter -->
|
||||
```
|
||||
|
||||
```js
|
||||
// Path A — proxy-tweened directional blur.
|
||||
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 — a seek to t=0 must show the streaked start, not a sharp pre-frame
|
||||
|
||||
tl.fromTo(
|
||||
"#streak-el",
|
||||
{ x: ENTER_FROM_X, opacity: 0 },
|
||||
{ x: 0, opacity: 1, duration: MOVE_DUR, ease: MOVE_EASE },
|
||||
MOVE_START,
|
||||
);
|
||||
tl.to(blurProxy, { v: 0, duration: MOVE_DUR, ease: MOVE_EASE, onUpdate: writeBlur }, MOVE_START);
|
||||
|
||||
// Path B — ghosts on the SAME window/ease; per-ghost variation by index.
|
||||
gsap.utils.toArray(".streak-ghost").forEach((g) => {
|
||||
const i = Number(g.dataset.i); // 1..N-1, set in HTML
|
||||
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,
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Vertical streak** — swap axes: `y`, `stdDeviation="0 Y"`, vertical echo offsets.
|
||||
- **Camera push-through** — `scale: SCALE_FROM → 1` with a symmetric `"B B"` envelope (depth-wise smear, not directional): the wordmark punches out of soft focus and snaps crisp at the lock.
|
||||
- **Staggered grid streak-in** — each card streaks into its slot at `MOVE_START + i * CARD_STAGGER` with its own blur proxy / ghosts; sharp the instant it lands.
|
||||
- **Hold-the-streak** — blur on a marginally slower curve than position (position `expo.out`, blur `power3.out`) so the last wisp resolves just after arrival. Sparingly; default is locked envelopes.
|
||||
|
||||
## Camera-Travel Carve-Out
|
||||
|
||||
The envelope is also sanctioned at **wrapper level**: on the `.world` / camera wrapper of a virtual-camera scene ([viewport-change.md](viewport-change.md), [multi-phase-camera.md](multi-phase-camera.md), [3d-camera-flight.md](3d-camera-flight.md)) during a **travel leg** — a dive, a whip sweep, a violent final push. This does **not** violate "never a mid-composition exit": the world never leaves frame — the camera travels _through_ it, and every leg ends with the world at rest, sharp, inside the frame. Each leg is an **arrival** at the next pose, so the entrance doctrine applies leg by leg. Three deltas from the element-level recipe:
|
||||
|
||||
- **Envelope follows the leg's ease.** An `out` leg (dive, final push) uses the base recipe unchanged. An `inOut` repositioning leg peaks mid-leg: split the envelope at the velocity peak — `0 → PEAK` on the in-half ease over the first half, `PEAK → 0` on the out-half over the second. Seed the proxy at **0** for these (the streaked state lives mid-leg, not at t=0; seed-at-`PEAK_BLUR` belongs to the entrance shape, where the first frame IS the fastest).
|
||||
- **Filter placement.** 2D camera: `filter: url(#streak)` on the `.world` wrapper. 3D flight: on the **perspective stage** above the 3D context — a `filter` on a `preserve-3d` element flattens it and collapses every `translateZ`. Never per-element inside the world: one frame-wide envelope, not N desynced ones.
|
||||
- **Full-frame blur is heavy** — cap `PEAK_BLUR` ~18–20 at wrapper level (vs 30 for one element); a brief whip may touch ~24. Axis rule as usual: `"X 0"` for a lateral whip/pan, `"B B"` for a dive/push.
|
||||
|
||||
### Whip sweep (named composition)
|
||||
|
||||
The heavily-blurred lateral whip that resolves into the next region — two rules on one window:
|
||||
|
||||
1. **Position** — [nudge-curve.md](nudge-curve.md)'s three-phase chain on the camera state, tuned burst-dominant (tail still ≥3× ramp-in in time).
|
||||
2. **Blur** — `0 → PEAK` across the ramp-in, held at `PEAK` through the linear burst (constant velocity = constant smear), `PEAK → 0` across the tail.
|
||||
|
||||
Swap or reveal the next region's content DURING the burst — the smear masks the change; the `power4.out` tail lands it sharp. Reveal during the burst, read after the tail.
|
||||
|
||||
```js
|
||||
tl.to(cam, { x: WHIP_X * 0.1, duration: 0.12, ease: "power3.in", onUpdate: applyCamera }, WHIP_AT);
|
||||
tl.to(
|
||||
cam,
|
||||
{ x: WHIP_X * 0.75, duration: 0.1, ease: "none", onUpdate: applyCamera },
|
||||
WHIP_AT + 0.12,
|
||||
);
|
||||
tl.to(
|
||||
cam,
|
||||
{ x: WHIP_X, duration: 0.35, ease: "power4.out", onUpdate: applyCamera },
|
||||
WHIP_AT + 0.22,
|
||||
);
|
||||
|
||||
tl.to(blurProxy, { v: PEAK_BLUR, duration: 0.12, ease: "power3.in", onUpdate: writeBlur }, WHIP_AT);
|
||||
// blur holds at PEAK through the linear burst (no tween needed — value rests at PEAK)
|
||||
tl.to(blurProxy, { v: 0, duration: 0.35, ease: "power4.out", onUpdate: writeBlur }, WHIP_AT + 0.22);
|
||||
```
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| ------------------ | -------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| MOVE_EASE | `expo.out` / `power4.out` (default) / `power3.out` | `out`-family ONLY — `in`/`inOut` puts peak speed in the wrong place; position and blur share it |
|
||||
| MOVE_DUR | 0.25–0.6s | over ~0.7s reads as a focus pull, not velocity |
|
||||
| ENTER_FROM_X/Y | 40–120% of the element's own dimension | enough runway for the streak to read |
|
||||
| PEAK_BLUR | 8–30 (default 18) | >30 erases the glyph at the start; ~18–20 cap at wrapper level |
|
||||
| SCALE_FROM | 1.3–2.5 | push-through variation |
|
||||
| N (ghosts) | 2–4 | >4 reads as strobe, not streak |
|
||||
| ECHO_STEP_PX | 12–40px | `N × step ≲ ENTER_FROM` so the furthest ghost starts inside the runway |
|
||||
| GHOST_BASE_OPACITY | 0.3–0.6 | opaque ghosts read as duplicate elements |
|
||||
| CARD_STAGGER | 0.05–0.12s | one assembling wave, not separate arrivals |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- Blur peaks at peak speed and resolves to 0 at the settle — share the ease and window between position and envelope. A blur that lingers after the stop reads as a focus pull.
|
||||
- Entrances / mid-shot arrivals only — never a mid-composition exit; wrapper-level use only per the carve-out.
|
||||
- Seed `stdDeviation` at setup: at `PEAK_BLUR` for the entrance shape, at 0 for a whip / `inOut` leg.
|
||||
- Generous filter region (`x="-50%" y="-50%" width="200%" height="200%"`) or the smear clips at the element's box edge.
|
||||
- Directional axis: `"X 0"` horizontal, `"0 Y"` vertical, `"B B"` only for a depth/scale move — symmetric blur on a sideways move looks like defocus.
|
||||
- Dwell ≥1 s sharp after the snap; a streak landing at the last beat reads as "flashed and gone".
|
||||
- Heavy element on a solid field — thin type (< ~120px / 800 weight) or a busy backdrop swallows the smear.
|
||||
- `overflow: hidden` on the scene — the smear / furthest ghost extends past the resting position during travel.
|
||||
|
||||
## See also
|
||||
|
||||
`kinetic-beat-slam` (streak as one beat's entrance) · `center-outward-expansion` (grid streak-in) · `scale-swap-transition` (same-footprint morph — not an arrival) · `nudge-curve` (the whip sweep's position half) · `3d-camera-flight` / `viewport-change` (the carve-out's wrappers).
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
name: multi-cursor-choreography
|
||||
description: N labeled independent cursor actors work one canvas simultaneously (collaborative-canvas ambience) — per-cursor deterministic waypoint schedules, name-tag pills in distinct colors, grab/drop actions on an interleaved beat grid so paths and actions never collide; the camera stays locked, the liveness itself is the message.
|
||||
metadata:
|
||||
tags: cursor, multi-cursor, collaboration, ensemble, canvas, name-tag, choreography, ambient, teamwork
|
||||
---
|
||||
|
||||
# Multi-Cursor Choreography
|
||||
|
||||
> **The camera never chases anyone.** No real camera — any "pan" is the canvas group translating inside a static frame. And per the motion doctrine's idle-motion ban, every cursor must **perform**: travel to a target, act, then rest still. Scheduled rest is stillness; aimless wander loops are wobble.
|
||||
|
||||
THE ensemble primitive: **two to four labeled cursor actors** — each an arrow plus a name-tag pill in its own color — work one shared canvas at the same time. No single interaction is the subject; the **simultaneous liveness is** ("a team is in here, working"), usually as ambience under a headline building over the top. Distinct from [cursor-click-ripple.md](cursor-click-ripple.md) and [cursor-drag.md](cursor-drag.md): those are **one protagonist** the viewer follows click-by-click; here the actors are chorus, not lead — each action smaller and quieter than a solo cursor's, the value in the interleaving. Also distinct from [camera-cursor-tracking.md](camera-cursor-tracking.md): that locks the _viewport_ to one focal cursor; this rule forbids exactly that — the frame is static and the eye roams freely.
|
||||
|
||||
## How It Works
|
||||
|
||||
Everything hangs off one data table:
|
||||
|
||||
1. **The actor table** — a literal `ACTORS` array: per actor a name, a color, and a **waypoint schedule** (`{ x, y, at, dur }` legs plus action beats). All coordinates and times are hand-authored constants — the choreography is data: deterministic, seekable, and auditable for collisions before a single frame renders.
|
||||
2. **Legs as explicit `fromTo`s** — each leg tweens the actor wrapper from the previous waypoint to the next at an absolute position. Gaps between legs are **rests**: the cursor sits still exactly where it landed.
|
||||
3. **Actions** — a leg can end in a grab (press dip; the payload rides the next leg in lockstep — [cursor-drag.md](cursor-drag.md) mechanics at chorus intensity), a drop (`tl.set` identity swap + tiny settle pop), or a hover (a highlight fades in under the tip, once, then holds).
|
||||
4. **The interleaved beat grid** — actions land on **alternating beats** (~1.2 / 2.6 / 4.0 s): at any moment at most one action lands while the others glide or rest. Each actor owns a home **zone** of the canvas; only one actor at a time leaves its zone, so paths never cross near-simultaneously. (Short specimens under ~5s can compress beat spacing to ~0.3–0.9s — zones still prevent collisions; the ≥1s spacing is for ambience-length shots.)
|
||||
5. **Ambience staging** — cursors may already be mid-canvas at t=0 (the team was working before we arrived — the collaborative-canvas idiom), or enter off-frame on staggered starts. The canvas group may slowly translate-pan under the ensemble (element translate, not a camera).
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<!-- Canvas group (mockups + payload chips) may translate for an ambient pan.
|
||||
One wrapper per actor: arrow + name tag move as ONE object. -->
|
||||
<div class="canvas-group" id="canvas-group">
|
||||
<div class="mockup" id="mockup-a">{mockupA}</div>
|
||||
<div class="canvas-chip" id="chip-1">{chipLabel}</div>
|
||||
</div>
|
||||
<div class="actor" id="actor-1">
|
||||
<svg class="actor-arrow"><!-- arrow path, fill: ACTOR_1_COLOR --></svg>
|
||||
<span class="actor-tag" style="background: ACTOR_1_COLOR">{actorName1}</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
```js
|
||||
// The choreography IS this table — all literals; read the `at` columns to
|
||||
// verify beats interleave. Each actor owns a zone.
|
||||
const ACTORS = [
|
||||
{
|
||||
id: "#actor-1", // zone: left mockup
|
||||
legs: [
|
||||
{ from: { x: 180, y: 420 }, to: { x: 320, y: 300 }, at: 0.2, dur: 0.9 },
|
||||
{ to: { x: 340, y: 480 }, at: 2.0, dur: 0.8 }, // rest 0.9s between legs
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "#actor-2", // zone: center mockup
|
||||
legs: [
|
||||
{ from: { x: 900, y: 200 }, to: { x: 820, y: 360 }, at: 0.6, dur: 1.0 },
|
||||
{ to: { x: 980, y: 380 }, at: 3.4, dur: 0.7 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "#actor-3", // zone: right panel — enters from off-frame
|
||||
legs: [{ from: { x: 1980, y: 520 }, to: { x: 1560, y: 460 }, at: 1.4, dur: 1.1 }],
|
||||
},
|
||||
];
|
||||
|
||||
ACTORS.forEach((actor) => {
|
||||
let prev = actor.legs[0].from;
|
||||
tl.set(actor.id, { x: prev.x, y: prev.y }, 0); // on stage (or off) from t=0
|
||||
actor.legs.forEach((leg) => {
|
||||
tl.fromTo(
|
||||
actor.id,
|
||||
{ x: prev.x, y: prev.y },
|
||||
{ x: leg.to.x, y: leg.to.y, duration: leg.dur, ease: "power2.inOut", immediateRender: false },
|
||||
leg.at,
|
||||
);
|
||||
prev = leg.to;
|
||||
});
|
||||
});
|
||||
|
||||
// Actions at chorus intensity — actor 1 grabs the chip: press dip, then the
|
||||
// chip rides leg 2 in lockstep (matched tween: same position, duration, ease).
|
||||
tl.to("#actor-1", { scale: 0.88, duration: 0.07, ease: "power2.in", yoyo: true, repeat: 1 }, 1.1);
|
||||
tl.fromTo(
|
||||
"#chip-1",
|
||||
{ x: 0, y: 0 },
|
||||
{ x: CHIP_DX, y: CHIP_DY, duration: 0.8, ease: "power2.inOut", immediateRender: false },
|
||||
2.0, // = actor-1 leg 2 `at` and `dur`, exactly
|
||||
);
|
||||
// Drop: identity swap + tiny settle — quieter than a solo cursor's snap
|
||||
tl.set("#chip-1", { backgroundColor: "{chipSwapColor}" }, 2.8);
|
||||
tl.fromTo(
|
||||
"#chip-1",
|
||||
{ scale: 1.06 },
|
||||
{ scale: 1, duration: 0.2, ease: "power3.out", immediateRender: false },
|
||||
2.8,
|
||||
);
|
||||
|
||||
// Optional ambient canvas pan (element translate, NOT a camera)
|
||||
tl.fromTo("#canvas-group", { x: 0 }, { x: PAN_DX, duration: 6.0, ease: "none" }, 0.3);
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Ambient collaborative canvas (the Hook register)** — the default: actors mid-canvas at t=0, canvas slowly panning, a headline building over the top ([waterfall-entry.md](waterfall-entry.md)). The demo is set-dressing for the words; keep every action small and the beat grid loose.
|
||||
- **One labeled editor (N = 1, still ensemble-styled)** — a single labeled teammate cursor performs one visible edit (deletes and retypes a headline word via [discrete-text-sequence.md](discrete-text-sequence.md), or drops one component). The name tag is the point: _a person_ did this.
|
||||
- **Featured beat inside the ensemble** — one actor briefly becomes the lead: full [cursor-drag.md](cursor-drag.md) grab-carry-drop with chrome while the others explicitly REST for that window. Freeze the chorus; two things moving with intent at once splits the eye.
|
||||
- **Staggered entrances** — cursors enter from off-frame at `ENTER_AT + i * ENTER_STAGGER`, each gliding to its zone ("the team assembles"); entry vectors from different edges, per the house cursor entry law.
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| ------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ACTOR_COUNT | 2–4 | one is a solo rule's job; five+ reads as noise — no viewer tracks five pointers |
|
||||
| leg `dur` | 0.6–1.2 s, `power2.inOut` | human, considered mouse movement; sub-0.5 s across long distances reads as a teleport |
|
||||
| rest gaps | 0.5–1.5 s | rests make the ensemble read as people; zero-rest actors read as screensavers |
|
||||
| action beat spacing | ≥ 1.0 s | while one acts, others may glide but must not act — audit by sorting all `at` values |
|
||||
| zones | one per actor | only the acting actor crosses zones; two cursors within ~80 px reads as a glitch — check waypoint pairs at overlapping times |
|
||||
| PAN_DX | ~40–80 px, linear | parallax life, not a camera move; omit for busier ensembles |
|
||||
| tag / arrow size | smaller than a solo lead | the oversized-cursor treatment is for protagonists; tags must stay legible at render resolution |
|
||||
| colors | one saturated hue each | from the palette's accent range; tag pill and arrow fill share the hue |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **The table is the choreography** — all waypoints, times, and actions are literal data. If you can't verify non-collision by reading the `at` columns, the schedule is too clever.
|
||||
- **Every leg is an explicit `fromTo`** with the previous waypoint as the from-state, `immediateRender: false` on all but each actor's initial placement — chained `.to()`s on shared properties capture stale starts under seek.
|
||||
- **Interleave, never chord** — at most one action landing at any moment; simultaneous travel is fine (that's the liveness), simultaneous _payoffs_ compete.
|
||||
- **Chorus intensity** — every action is a quieter version of its solo rule: smaller dips, subtler snaps, no ripple bursts; save full treatment for a featured beat.
|
||||
- **Rest is stillness** — between legs a cursor holds exactly where it landed: no idle drift, no yoyo wander on any actor.
|
||||
- **Payload lockstep** — a carried chip's tween matches its actor's leg exactly (position, duration, ease), per the cursor-drag law.
|
||||
- **The wrapper moves, never the parts** — arrow + name tag are one element; tweening them separately shears the actor apart under seek.
|
||||
- **Camera locked** — no viewport zoom/pan tweens; the only large-scale motion is the linear canvas-group translate. Never zoom to an actor (that's a solo-cursor shot).
|
||||
- **Actors are people** — human-speed glides, pauses, one thing at a time; `pointer-events: none` on all actors. Check `tl.duration()` — ensembles accumulate long tails from late rests.
|
||||
|
||||
## See also
|
||||
|
||||
`cursor-drag` (full-treatment featured beat) · `cursor-click-ripple` (chorus click — press only, skip the ripple) · `discrete-text-sequence` (a labeled actor's retype edit) · `viewport-change` (the canvas-group translate math) · `spring-pop-entrance` (components popping in as drop results).
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
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, with continuous sine-driven micro-drift overlaid 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 one wrapping `<div>` whose `transform: scale() translate(x, y)` is composed from two channels inside a single `onUpdate` writer:
|
||||
|
||||
1. **Phase scale** — a proxy object `{ scale }` stepped through phases at trigger times (`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. X and Y run at slightly different frequencies (`DRIFT_FREQ_RATIO ≈ 1.3`) — equal frequencies produce a perfect diagonal that reads mechanical; ~1.3 gives an organic Lissajous.
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<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>
|
||||
```
|
||||
|
||||
```css
|
||||
.scene {
|
||||
overflow: hidden; /* REQUIRED — any phase scale < 1 exposes the content's edges */
|
||||
background: {sceneBgColor}; /* background on .scene, NOT .camera — a camera-borne
|
||||
background warps/translates with the transform and reveals the outer void */
|
||||
}
|
||||
.camera {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transform-origin: 50% 50%; /* off-center origin creates phase-to-phase drift */
|
||||
will-change: transform;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
const camera = document.getElementById("camera");
|
||||
|
||||
// Three-phase scale plan: pullback → focus → push.
|
||||
const phase = { scale: PHASE_1_SCALE }; // Phase 1 is the initial value — no tween
|
||||
|
||||
// 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.
|
||||
// The ONE writer of camera.style.transform.
|
||||
const drift = { p: 0 };
|
||||
tl.to(
|
||||
drift,
|
||||
{
|
||||
p: Math.PI * 2 * DRIFT_CYCLES,
|
||||
duration: TOTAL_DURATION, // spans the whole composition
|
||||
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 happen INSIDE the camera frame (hero/tagline/cta beats).
|
||||
```
|
||||
|
||||
## Phase Patterns
|
||||
|
||||
| Pattern | Scale sequence (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**: align a camera tween's start with a content tween's end (entry completes → push begins) rather than a fixed clock value.
|
||||
- **Camera shake (panic / impact)**: a brief higher-amplitude, higher-frequency drift tween over a short window — same `drift` mechanism with `SHAKE_AMP` / `SHAKE_CYCLES` / `SHAKE_DUR` at `SHAKE_AT`.
|
||||
- **Targeted zoom into an off-center element**: combine scale with counter-translation so the target lands at viewport center — divide the measured offset by the current scale before feeding it into the writer:
|
||||
|
||||
```js
|
||||
const tRect = document.querySelector(".cta").getBoundingClientRect();
|
||||
const offsetX = (STAGE_W / 2 - (tRect.left + tRect.width / 2)) / phase.scale;
|
||||
const offsetY = (STAGE_H / 2 - (tRect.top + tRect.height / 2)) / phase.scale;
|
||||
// then in onUpdate: translate(offsetX + dx, offsetY + dy)
|
||||
```
|
||||
|
||||
(Full counter-translate doctrine: [coordinate-target-zoom.md](coordinate-target-zoom.md).)
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| --------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| PHASE_1 / 2 / 3_SCALE | 0.88–0.96 / 0.98–1.02 / 1.04–1.15 | tighter spread = subtler camera; scale < 1 REQUIRES `overflow: hidden` on `.scene` |
|
||||
| PHASE_2_AT / PHASE_2_DUR | 0.3–1.0s / 1.0–1.8s | longer DUR = slower settle, more cinematic |
|
||||
| PHASE_3_AT / PHASE_3_DUR | 2.0–4.0s / 1.0–2.0s | PHASE_3_AT ≥ PHASE_2_AT + PHASE_2_DUR or focus is preempted |
|
||||
| PHASE_2_EASE / PHASE_3_EASE | `power2.out` `power3.out` `power2.inOut` | spring/back easing on a camera feels uncomfortable; each later phase settles deeper |
|
||||
| TOTAL_DURATION | = `data-duration` | the drift tween must span the whole composition |
|
||||
| DRIFT_CYCLES | 1–3 | 1 = one slow breath; high values read as mechanical wobble |
|
||||
| DRIFT_AMP_X / DRIFT_AMP_Y | 2–8 px / 1–4 px | imperceptible per-frame, visible over time — if it reads as a shake, it's too much |
|
||||
| DRIFT_FREQ_RATIO | 1.2–1.5 | 1.0 = perfect diagonal (mechanical); ~1.3 = organic Lissajous |
|
||||
| HERO_AT (etc.) | after Phase-2 settle lands | a hero fading in mid-pull-back feels like it's flying away |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Camera wraps EVERYTHING in the scene** — a per-element camera creates parallax bugs and breaks the "one viewpoint" read.
|
||||
- **One writer**: phase scale and drift compose inside the single drift `onUpdate`; nothing else touches `camera.style.transform`.
|
||||
- **`overflow: hidden` on `.scene`** — required whenever any phase scale < 1.
|
||||
- **`transform-origin: 50% 50%` on `.camera`** — off-center origin creates unpredictable phase-to-phase drift.
|
||||
- **Scene background on `.scene`, not `.camera`** — otherwise scaling/translating reveals the outer void.
|
||||
- **Hero reveal starts AFTER the initial pull-back ease lands** — otherwise the headline feels like it's flying away.
|
||||
|
||||
## See also
|
||||
|
||||
[coordinate-target-zoom.md](coordinate-target-zoom.md) (counter-translate math for the targeted variation) · [orbit-3d-entry.md](orbit-3d-entry.md) (orbit inside a drifting camera) · [counting-dynamic-scale.md](counting-dynamic-scale.md) (climax push synced to counter peak) · [3d-text-depth-layers.md](3d-text-depth-layers.md) (depth-stacked hero under camera moves) · [sine-wave-loop.md](sine-wave-loop.md) (element idle inside the camera).
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: nudge-curve
|
||||
description: Slow-fast-slow three-phase group slide — reposition a composed group (word rows, card stacks, lists) to reveal content or make room. No single built-in ease produces it; chain power3.in ramp → linear burst → power4.out tail (10/65/25 distance, tail ≥3× ramp-in in time).
|
||||
metadata:
|
||||
tags: slide, reposition, group-motion, easing, nudge, slow-fast-slow, reveal, layout
|
||||
---
|
||||
|
||||
# Nudge Curve
|
||||
|
||||
Slow-fast-slow repositioning of a composed group (word rows, card stacks, lists) to
|
||||
reveal content or make room. **In-scene group slide — not a seam.** No single built-in
|
||||
ease produces it — `power4.inOut` smacks to a stop. Chain three tweens on one property:
|
||||
|
||||
| Phase | Ease | Distance | Time | Feel |
|
||||
| --------- | --------------- | -------- | ---- | ---------------------------------------- |
|
||||
| 1 ramp-in | `power3.in` | ~10% | ~20% | barely moves — motion registers, no jolt |
|
||||
| 2 burst | `none` (linear) | ~65% | ~18% | ~2× average px/frame — purposeful |
|
||||
| 3 tail | `power4.out` | ~25% | ~62% | decaying creep to rest — kills the smack |
|
||||
|
||||
## Rules
|
||||
|
||||
- The tail is ≥3× the ramp-in in TIME. If it still smacks: extend the tail's time (not
|
||||
distance) or use `power5.out`.
|
||||
- Phase 2 stays linear — easing it loses the burst contrast.
|
||||
- Reveal new content DURING phase 2 — the burst masks its appearance.
|
||||
- Same ratios vertical; scale distances proportionally, keep the time ratios.
|
||||
- A cascade arrival usually precedes this slide — see [waterfall-entry.md](waterfall-entry.md).
|
||||
|
||||
## JS
|
||||
|
||||
Reference values for a 270px leftward slide (0.57s total). Scale distances
|
||||
proportionally for other travels; preserve the TIME ratios; tail ≥3× ramp-in.
|
||||
|
||||
```js
|
||||
var t = /* start after content settles */;
|
||||
tl.to(".text-row", { x: -30, duration: 0.12, ease: "power3.in" }, t); // ramp-in: 11% dist / 21% time
|
||||
tl.to(".text-row", { x: -210, duration: 0.10, ease: "none" }, t + 0.12); // burst: 67% dist / 18% time
|
||||
tl.to(".text-row", { x: -270, duration: 0.35, ease: "power4.out" }, t + 0.22); // tail: 22% dist / 61% time
|
||||
// vertical: same ratios on y. 150px variant: -15 / -115 / -150 at the same times.
|
||||
```
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
| Don't | Instead |
|
||||
| -------------------------------------------------------- | ---------------------------------------- |
|
||||
| Single ease for a group slide (`power4.inOut`, `slow()`) | The three-phase chain above |
|
||||
| Nudge tail shorter than 3× the ramp-in | Extend the tail's TIME, not its distance |
|
||||
@@ -0,0 +1,148 @@
|
||||
---
|
||||
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` + negative `z`) then settle into a continuous elliptical orbit around a center label. Distinct from one-shot reveals — the orbit keeps running, driven by a 0→1 progress tween INSIDE the timeline (never rAF).
|
||||
|
||||
## How It Works
|
||||
|
||||
Per element, two phases: (1) a `back.out` flip from a hidden 3D orientation to flat — **in place at its orbital starting position** (see Critical Constraints); (2) a continuous orbit where `onUpdate` computes `x/y` from `cos/sin(initialAngle + p·2π)` on the ellipse. The stage needs `perspective` on the scene root and `preserve-3d` on stage + items, or the flip flattens to a 2D scale.
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<!-- inside a standard scene clip (hyperframes-core) -->
|
||||
<div class="orbit-stage">
|
||||
<div class="orbit-item" data-angle="0">{glyph1}</div>
|
||||
<div class="orbit-item" data-angle="60">{glyph2}</div>
|
||||
<!-- … evenly-spaced angles … -->
|
||||
<div class="orbit-center">{centerLabel}</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
```css
|
||||
.scene-root {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
perspective: 1800px; /* REQUIRED */
|
||||
}
|
||||
.orbit-stage {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
.orbit-item {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform-style: preserve-3d;
|
||||
will-change: transform;
|
||||
}
|
||||
.orbit-center {
|
||||
position: relative;
|
||||
transform: translateZ(220px); /* wins paint order inside preserve-3d */
|
||||
z-index: 9999;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
const items = document.querySelectorAll(".orbit-item");
|
||||
const RADIUS_Y = RADIUS_X * Y_TO_X_RATIO; // perspective-flattened ellipse
|
||||
|
||||
items.forEach((el, i) => {
|
||||
const a0 = (Number(el.dataset.angle) / 360) * Math.PI * 2;
|
||||
const startX = Math.cos(a0) * RADIUS_X;
|
||||
const startY = Math.sin(a0) * RADIUS_Y;
|
||||
|
||||
// 1) Park at the orbital position, hidden — 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) Flip in IN PLACE — rotation/opacity/scale only, never translate
|
||||
tl.to(
|
||||
el,
|
||||
{
|
||||
rotateX: 0,
|
||||
rotateY: 0,
|
||||
z: 0,
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
duration: ENTRY_DUR,
|
||||
ease: `back.out(${FLIP_BACK})`,
|
||||
},
|
||||
i * STAGGER,
|
||||
);
|
||||
|
||||
// 3) Continuous orbit — each item gets its OWN progress tween (own initialAngle)
|
||||
const orbit = { p: 0 };
|
||||
tl.to(
|
||||
orbit,
|
||||
{
|
||||
p: 1,
|
||||
duration: ORBIT_DURATION,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
const a = a0 + orbit.p * Math.PI * 2;
|
||||
const x = Math.cos(a) * RADIUS_X;
|
||||
const y = Math.sin(a) * RADIUS_Y;
|
||||
// capped z-index band [1, 50] — see center-label clearance below
|
||||
el.style.zIndex = String(1 + Math.round(((y + RADIUS_Y) / (2 * RADIUS_Y)) * 49));
|
||||
el.style.transform = `translate(-50%, -50%) translate(${x}px, ${y}px)`;
|
||||
},
|
||||
},
|
||||
i * STAGGER + ENTRY_DUR,
|
||||
);
|
||||
});
|
||||
|
||||
tl.from(
|
||||
".orbit-center",
|
||||
{ opacity: 0, scale: 0.6, duration: ENTRY_DUR, ease: `back.out(${CENTER_BACK})` },
|
||||
CENTER_FADE_AT,
|
||||
);
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Collapse to center**: a final 1→0 driver multiplies both radii (and item scale) in `onUpdate` — the ring condenses into the center element; pairs with a CTA "click" igniting the collapse.
|
||||
- **Tilted orbit plane**: `rotateX(25deg)` on `.orbit-stage` — items visibly arc through the plane.
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| ----------------------- | ----------------------------- | ------------------------------------------------------------------- |
|
||||
| RADIUS_X | 300–900px | must also clear the center label horizontally (see below) |
|
||||
| Y_TO_X_RATIO | 0.4–0.7 | keep < 1 — a tilted ring, not a frontal halo |
|
||||
| ORBIT_DURATION | 4–25s per revolution | ≥ time on screen, or the tween ends and items freeze |
|
||||
| ENTRY_DUR | 0.4–0.8s | |
|
||||
| STAGGER | 0.06–0.12s | below reads "popcorn", above reads plodding |
|
||||
| FLIP_BACK / CENTER_BACK | 1.2–2.0 / 1.2–1.8 | calm the center pop if both fire close together |
|
||||
| CENTER_FADE_AT | after 2–4 items land | too early competes; too late leaves a hole |
|
||||
| ROTATE_X/Y_FROM, Z_FROM | ±60–120°, ±45–120°, −200…−400 | one consistent rotation direction across items; mixed signs = noise |
|
||||
| SCALE_FROM | 0.2–0.6 | |
|
||||
| item count | 4–12 | fewer feels empty, more crowds the center |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **❗ Entry must flip IN PLACE at the orbital position, NOT at center** — `gsap.set` each item at `(cos(a0)·RADIUS_X, sin(a0)·RADIUS_Y)` with `opacity: 0` BEFORE adding tweens, then phase 1 animates only rotation/opacity/scale. A fromTo that keeps `x/y: 0` flips at the stage center, collides with the center label, then teleports to the orbit when phase 2 starts.
|
||||
- **❗ Center-label clearance** — `z-index` alone is unreliable inside `preserve-3d` (paint order follows actual Z): push the label forward with `translateZ(220px)` + `z-index: 9999`, cap item z-index to `[1, 50]`, AND size the ring so items clear the label horizontally at every angle: `RADIUS_X × min|cos(θ)| ≥ L_w + I_w + breathing_room` (label/item half-widths; for 6 items the worst case is `cos(30°) ≈ 0.866`). A heavier wordmark needs a wider ring.
|
||||
- **Each item gets its OWN orbit tween** — a shared `targets: ".orbit-item"` tween can't carry per-item `initialAngle`.
|
||||
- **The center element is the headline** — the orbit is ornament; if it dominates, grow the center or fade the items down.
|
||||
|
||||
## See also
|
||||
|
||||
`center-outward-expansion` (burst entry; reversed driver = the collapse finish) · `cursor-click-ripple` (the click that triggers a collapse) · `depth-scatter-assemble` (3D entrance that resolves flat instead of orbiting).
|
||||
@@ -0,0 +1,149 @@
|
||||
---
|
||||
name: particle-burst
|
||||
description: Deterministic particle / confetti events — a confetti pop that bursts up and drifts down (optionally instant-shrinking away), a dot burst from behind text, or a glyph dissolving to particles. Every particle's state is a pure ballistic function of timeline time from index-seeded values, so a scrub to any t shows the correct mid-flight frame.
|
||||
metadata:
|
||||
tags: particles, confetti, burst, dissolve, celebration, ballistic, deterministic, punctuation
|
||||
---
|
||||
|
||||
# Particle Burst
|
||||
|
||||
Discrete flying particles as a one-shot event: a **confetti pop** that erupts upward and drifts back down on gravity, a **dot burst** radiating from behind a landing word, or a **glyph dissolve** where text breaks into particles that scatter and die. Particles are ephemeral garnish — born from a beat, fly, gone; they never become layout.
|
||||
|
||||
Boundaries: [css-marker-patterns.md](css-marker-patterns.md)'s burst mode is radiating **drawn lines** — a static accent, no flight. [press-release-spring.md](press-release-spring.md)'s release burst is **one blurred radial layer** faking an explosion — enough when a single glow pop will do. [center-outward-expansion.md](center-outward-expansion.md) moves **real layout elements** to final resting slots; particles have no destination, only physics and a death.
|
||||
|
||||
## How It Works
|
||||
|
||||
The whole event is **one driver tween and one formula**:
|
||||
|
||||
1. **Seeded setup** — a fixed pool of `PARTICLE_COUNT` small divs is created once at composition setup (a deterministic loop — setup-time generation is fine; per-frame DOM creation is not). Each particle `i` derives everything from a pure hash:
|
||||
|
||||
```js
|
||||
// angle, speed, size, spin, color (palette[i % palette.length]) — all from prand(i * k)
|
||||
const prand = (n) => {
|
||||
const x = Math.sin(n * 127.1 + 311.7) * 43758.5453;
|
||||
return x - Math.floor(x); // 0..1, pure function of n
|
||||
};
|
||||
```
|
||||
|
||||
2. **Ballistic formula** — a proxy tween advances `T: 0 → 1` over `FLIGHT_DUR` with `ease: "none"`; `onUpdate` positions every particle as a **pure function of T**:
|
||||
|
||||
```
|
||||
x(T) = vx · T·FLIGHT_DUR
|
||||
y(T) = vy · T·FLIGHT_DUR + ½ · G · (T·FLIGHT_DUR)²
|
||||
rot(T) = spin · T·FLIGHT_DUR
|
||||
```
|
||||
|
||||
Gravity `G` supplies the rise-decelerate-fall arc for free. Because position is computed from `T` (never accumulated per frame), a seek to any moment renders the exact mid-flight state — this is what makes DOM particles seek-safe. The driver's `ease: "none"` is load-bearing: the physics lives in the formula; an eased driver warps gravity and the arc stops reading as thrown objects.
|
||||
|
||||
3. **Death** — an opacity tail inside the same formula (fade over the last `FADE_FRAC` of flight), or the confetti signature: a separate **instant-shrink** tween scaling the pool to 0 in a blink at flight end. Either way the particles end invisible and stay invisible.
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<!-- inside a standard scene clip (hyperframes-core) -->
|
||||
<div class="burst-stage">
|
||||
<div class="particle-field" id="particle-field"></div>
|
||||
<div class="burst-hero" id="burst-hero">{heroWord}</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
```css
|
||||
/* .burst-stage: position: relative; display: grid; place-items: center.
|
||||
.burst-hero: z-index: 2 — particles fly BEHIND the word. */
|
||||
.particle-field {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
left: 50%;
|
||||
top: 50%; /* the launch origin — offset to taste (e.g. the word's baseline) */
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.particle {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
border-radius: 2px; /* confetti chip; 50% for dots */
|
||||
opacity: 0; /* invisible until the event fires */
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// Setup: deterministic pool, generated ONCE.
|
||||
const field = document.getElementById("particle-field");
|
||||
const palette = ["{accentA}", "{accentB}", "{accentC}"]; // 3-5 brand tokens
|
||||
const parts = [];
|
||||
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "particle";
|
||||
const size = SIZE_MIN + prand(i * 3 + 1) * (SIZE_MAX - SIZE_MIN);
|
||||
el.style.width = `${size}px`;
|
||||
el.style.height = `${size * 0.7}px`; // slightly oblong = confetti chip
|
||||
el.style.background = palette[i % palette.length];
|
||||
field.appendChild(el);
|
||||
// Index-seeded launch parameters — the particle's whole life, fixed here.
|
||||
const angle = -Math.PI / 2 + (prand(i * 5 + 2) * 2 - 1) * CONE; // upward cone
|
||||
const speed = SPEED_MIN + prand(i * 7 + 3) * (SPEED_MAX - SPEED_MIN);
|
||||
parts.push({
|
||||
el,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed, // negative = up
|
||||
spin: (prand(i * 11 + 4) * 2 - 1) * SPIN_MAX,
|
||||
});
|
||||
}
|
||||
|
||||
// Confetti pop — one driver, pure ballistic formula.
|
||||
const drive = { T: 0 };
|
||||
tl.fromTo(
|
||||
drive,
|
||||
{ T: 0 },
|
||||
{
|
||||
T: 1,
|
||||
duration: FLIGHT_DUR,
|
||||
ease: "none", // physics lives in the formula, not the ease
|
||||
onUpdate: () => {
|
||||
const t = drive.T * FLIGHT_DUR; // seconds of flight — pure function of T
|
||||
const fade = Math.min(1, (1 - drive.T) / FADE_FRAC); // opacity tail
|
||||
parts.forEach((p) => {
|
||||
const x = p.vx * t;
|
||||
const y = p.vy * t + 0.5 * G * t * t; // rise, stall, drift down
|
||||
p.el.style.transform = `translate(${x}px, ${y}px) rotate(${p.spin * t}deg)`;
|
||||
p.el.style.opacity = String(drive.T === 0 ? 0 : fade); // T===0 guard covers seeks before the event
|
||||
});
|
||||
},
|
||||
},
|
||||
BURST_AT,
|
||||
);
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Confetti pop, then instant-shrink** — the playful signature: full burst, gravity drift, then every chip scales to 0 in a blink: `FADE_FRAC` near 0, plus `tl.to(".particle", { scale: 0, duration: SHRINK_DUR, ease: "power2.in" }, BURST_AT + FLIGHT_DUR - SHRINK_DUR)` with `SHRINK_DUR` 0.15–0.25s. Keep the whole event tiny relative to the subject — a garnish measured in a few dozen pixels, not a screen-filling cannon.
|
||||
- **Dot burst behind a landing word** — radial instead of a cone: `angle = prand(i) * Math.PI * 2`, `G` near 0, short flight (0.4–0.7s), round dots (`border-radius: 50%`), pool z-indexed behind the word. Fire at the word's settle frame.
|
||||
- **Glyph dissolve** — seed each particle's **origin** across the glyph block's box (`ox = (prand(i*13) - 0.5) * BLOCK_W`, same for `oy`, added inside the transform), gentle outward drift with low `G`; text fades out over the first ~30% of flight while particles fade in from its silhouette. Color every particle `{textColor}` so the swarm reads as the text's own material. (True per-pixel dissolves are Canvas-2D territory — `techniques.md`; this DOM version sells it up to ~40 particles.)
|
||||
- **Two-stage burst (pop + stragglers)** — split the pool: 70% on the main driver, 30% on a second driver ~0.12s later with lower speeds; the split is index-derived (`i % 10 < 3`). Same formula, two windows.
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| --------------------- | -------------------------------------------- | ------------------------------------------------------------------------------- | --- | ----------------------- |
|
||||
| PARTICLE_COUNT | 10–18 pop/dots; 24–40 dissolve | **cap ~40** — per-frame style writes; past that, seek perf and register degrade |
|
||||
| G | 900–1600 px/s² confetti; 0–200 dots/dissolve | natural fall vs drift |
|
||||
| SPEED_MIN / SPEED_MAX | 250–700 px/s | per-particle via `prand`, never uniform |
|
||||
| CONE | 0.35–0.8 rad (~20–45°) | wider = splash, narrower = fountain |
|
||||
| FLIGHT_DUR | 0.7–1.4s | arc should peak ~35–45% of flight: check ` | vy | / G ≈ 0.4 × FLIGHT_DUR` |
|
||||
| SIZE_MIN / SIZE_MAX | 5–14px chips; 4–8px dots | on a 1080p frame |
|
||||
| SPIN_MAX | 180–720 deg/s confetti; 0 dots | tumble |
|
||||
| FADE_FRAC | 0.2–0.35 | near 0 when using instant-shrink |
|
||||
| BURST_AT | on a cause | the word's settle, a click, a lockup completing — an uncaused burst is noise |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Position is a pure function of time, driver ease `"none"`** — `x(T)`, `y(T)`, `rot(T)` computed from the driver value every frame, never accumulated (`+=`) per tick (accumulation breaks the moment the renderer seeks); gravity is the ease — an eased driver bends the parabola.
|
||||
- **Fixed pool, no per-frame DOM** — all particles exist after setup with `opacity: 0`; the event only writes `transform` / `opacity`. **`PARTICLE_COUNT ≤ ~40`** — per-frame style writes scale linearly; keep the event cheap.
|
||||
- **Particles start AND end at `opacity: 0`** — the `drive.T === 0` guard covers seeks to before the event; the tail/shrink covers after. A chip frozen mid-air at driver end is a bug every subsequent frame.
|
||||
- **Particles are punctuation** — one event per beat, fired on a cause, small relative to the subject, dead before the next beat; z-ordered behind or around the word it celebrates, never over it. A persistent particle system is a background, and that's not this rule.
|
||||
|
||||
## See also
|
||||
|
||||
`spring-pop-entrance` (confetti fires on the hero's settle frame) · `kinetic-beat-slam` (one beat earns the confetti payoff) · `press-release-spring` (single-layer glow alternative, or compose both) · `css-marker-patterns` (drawn-line burst when the accent should feel hand-annotated) · `scale-swap-transition` (glyph dissolve covers the exit).
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
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. Distinct from [press-release-spring.md](press-release-spring.md) (no cursor — just a press happening); this rule is the COMBINED cursor + element behavior. A single `PRESS_INTENSITY` drives both: press down compresses both to `1 - PRESS_INTENSITY` via **one targets array**, release springs both back to 1.0 with overshoot. The cursor translates to the button's center BEFORE the press starts; after release it may move on or hold.
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<button class="btn" id="btn">{ctaCopy}</button>
|
||||
<!-- Cursor at scene-root level so it translates freely; arrow TIP is the click
|
||||
point, so transform-origin: 0 0 — scaling around the tip keeps it stable. -->
|
||||
<svg class="cursor" id="cursor" style="pointer-events: none; transform-origin: 0 0">…</svg>
|
||||
```
|
||||
|
||||
```js
|
||||
gsap.set("#cursor", { x: CURSOR_START_X, y: CURSOR_START_Y }); // off-screen / far corner
|
||||
|
||||
// Phase 1 — approach
|
||||
tl.to(
|
||||
"#cursor",
|
||||
{ x: BUTTON_CENTER_X, y: BUTTON_CENTER_Y, duration: APPROACH_DUR, ease: "power2.inOut" },
|
||||
APPROACH_START,
|
||||
);
|
||||
|
||||
// Phase 2 — coordinated press down: ONE targets array, same scale
|
||||
tl.to(
|
||||
["#btn", "#cursor"],
|
||||
{ scale: 1 - PRESS_INTENSITY, duration: PRESS_DOWN_DUR, ease: "power1.in" },
|
||||
PRESS_DOWN_AT,
|
||||
);
|
||||
|
||||
// Phase 3 — release: both spring back together
|
||||
tl.to(
|
||||
["#btn", "#cursor"],
|
||||
{ scale: 1, duration: RELEASE_DUR, ease: `back.out(${BOUNCE_FACTOR})` },
|
||||
RELEASE_AT,
|
||||
);
|
||||
|
||||
// Phase 4 — inner glow during press, resting shadow on release (contact confirmation)
|
||||
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,
|
||||
);
|
||||
|
||||
// Cursor optionally exits after the press settles
|
||||
tl.to(
|
||||
"#cursor",
|
||||
{ x: CURSOR_EXIT_X, y: CURSOR_EXIT_Y, duration: CURSOR_EXIT_DUR, ease: "power2.out" },
|
||||
CURSOR_EXIT_AT,
|
||||
);
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Multiple-element chain press** — press button A → A triggers a 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: both scales stay at `1 - PRESS_INTENSITY`, inner glow stays on. Suggests "thinking" or "loading."
|
||||
- **Synchronized inner-glow pulse** — during the hold, pulse the inset glow with a sine driver: a `{ p: 0 }` proxy tweened to `Math.PI * GLOW_PULSE_CYCLES * 2` on `ease: "none"`, `onUpdate` writing `boxShadow` with `alpha = GLOW_BASE_ALPHA + sin(p) * GLOW_PULSE_AMP`. Suggests "processing."
|
||||
|
||||
## Values
|
||||
|
||||
| token | range / rule | notes |
|
||||
| ------------------- | ---------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| APPROACH_START | 0–0.3 s | long delays read as a dead frame |
|
||||
| APPROACH_DUR | 0.7–1.3 s | faster = urgent, slower = deliberate |
|
||||
| PRESS_DOWN_AT | `= APPROACH_START + APPROACH_DUR` | cursor arrives exactly as the press begins — avoids "tapping on air" |
|
||||
| PRESS_DOWN_DUR | 0.1–0.25 s | |
|
||||
| RELEASE_AT | > `PRESS_DOWN_AT + PRESS_DOWN_DUR` | optional 0.05–0.4 s hold (or `HOLD_DUR` 0.3–0.8 s) for "thinking" interactions |
|
||||
| RELEASE_DUR | 0.4–0.7 s | long enough for the overshoot to settle |
|
||||
| PRESS_INTENSITY | 0.05 subtle · 0.10 standard · 0.15 heavy | applied to both cursor and button via the single targets array |
|
||||
| BOUNCE_FACTOR | 1.6 soft · 2.0 firm · 2.4 cartoony | |
|
||||
| CURSOR_START / EXIT | off-screen or far corner | the approach must read as motion-in, not a teleport; exit ≥ `RELEASE_AT + RELEASE_DUR` |
|
||||
| BUTTON_CENTER | measured | for `place-items: center` at 1920×1080: `(960, 540)` |
|
||||
| BRAND_REVEAL_AT | < `PRESS_DOWN_AT` | context precedes interaction |
|
||||
| glow pulse | 1–4 cycles; base α 0.15–0.3; amp 0.1–0.2 | `GLOW_BASE_ALPHA − GLOW_PULSE_AMP ≥ 0` |
|
||||
| CURSOR_SIZE | 48–96 px at 1080p | |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Same press scale on cursor AND button** (one targets array) — only the button scaling makes the cursor "tap on air"; only the cursor scaling makes the button feel disconnected.
|
||||
- **Cursor arrives BEFORE the press starts** — a clear "cursor over target" moment, or the press is unattributed.
|
||||
- **`back.out(BOUNCE_FACTOR)` on the release, for both together** — a linear release loses the tactile feel; release MUST come after press.
|
||||
- **Inner glow appears DURING press, fades on release** — outer shadow shrinks (pushed in), inner glow appears (energy concentrated).
|
||||
- **Cursor `transform-origin: 0 0`** — the arrow's tip is the click point; scale around the tip keeps it stable. `pointer-events: none` on the cursor.
|
||||
- **Climax dwell ≥ 1 s** — after release the composition must continue ≥ 1 s; the press is a beat, the viewer needs time to see the result.
|
||||
- **No real `mouseenter` / `click` events** — HF is a render context; everything runs via the timeline.
|
||||
|
||||
## See also
|
||||
|
||||
`press-release-spring` (the BUTTON-only press; this rule layers the cursor on top) · `cursor-click-ripple` (adds a ripple at the click point) · `scale-swap-transition` (the press TRIGGERS the swap).
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
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, with secondary motion (shadow shrink, release burst, background glow) layered on the same trigger frame. This is a **reaction on an element already resting on screen** — an arrival that springs in from nothing is [spring-pop-entrance.md](spring-pop-entrance.md); add a visible cursor actor and it becomes [physics-press-reaction.md](physics-press-reaction.md).
|
||||
|
||||
Two phases split at the **release**:
|
||||
|
||||
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 back to 1.0. Optional burst glow ring expands behind the button; optional 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** — `RELEASE_START = PRESS_START + PRESS_DUR`; a gap or overlap breaks it.
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<div class="press-stage">
|
||||
<div class="bg-glow" id="bg-glow"></div>
|
||||
<!-- Burst sits BEHIND the button (z-index 1 vs 2), same footprint, blurred
|
||||
radial gradient, opacity 0. bg-glow is a full-stage radial at negative
|
||||
inset so it extends past the stage edges. -->
|
||||
<div class="burst" id="burst"></div>
|
||||
<button class="btn" id="btn">{buttonLabel}</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
```js
|
||||
// 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; start scale == PRESS_SCALE by adjacency)
|
||||
tl.to(
|
||||
"#btn",
|
||||
{
|
||||
scale: 1,
|
||||
boxShadow: "{btnRestShadow}",
|
||||
duration: RELEASE_DUR,
|
||||
ease: `back.out(${BOUNCE_FACTOR})`,
|
||||
},
|
||||
RELEASE_START,
|
||||
);
|
||||
|
||||
// Phase 3 — burst glow pops behind the button, then fades
|
||||
tl.fromTo(
|
||||
"#burst",
|
||||
{ scale: 1, opacity: 0 },
|
||||
{
|
||||
scale: BURST_PEAK_SCALE,
|
||||
opacity: BURST_PEAK_OPACITY,
|
||||
duration: BURST_GROW_DUR,
|
||||
ease: "power2.out",
|
||||
},
|
||||
RELEASE_START,
|
||||
);
|
||||
tl.to("#burst", { opacity: 0, duration: BURST_FADE_DUR, ease: "power2.in" }, BURST_FADE_START);
|
||||
|
||||
// Phase 4 — 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,
|
||||
);
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Subtle press** (status save / muted CTA): `PRESS_SCALE` ~0.96, `BOUNCE_FACTOR` ~1.4, burst scale/opacity reduced.
|
||||
- **Dramatic press** (hero CTA / "ship it"): `PRESS_SCALE` ~0.88, `BOUNCE_FACTOR` ~2.5, burst maxed.
|
||||
- **Color shift during press** — darken mid-press, return on release; interpolated `backgroundColor` at the same timeline positions as the scale tweens. Same state-continuity rule.
|
||||
- **State change at release** (approve / confirm) — instead of returning to the rest color, swap to `{successColor}` at `RELEASE_START` and pop a checkmark via a separate `back.out(CHECK_BOUNCE)` tween (1.4–2.0, firmer than the button's bounce — a punctuating "stamp"; pop 0.3–0.6 s) at the same position. The button is now terminal — no further presses expected.
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| -------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------ |
|
||||
| button footprint | ≥ 3–5% of canvas area | a 320×68 button at 1080p is ~1% and the press reads as visually insignificant |
|
||||
| PRESS_SCALE | 0.88 dramatic · 0.92 default · 0.96 subtle | never <0.85 (broken) or >0.98 (no perceptible dip) |
|
||||
| PRESS_DUR | 0.10–0.30 s | shorter = snappier; must be shorter than `RELEASE_DUR` (input faster than spring recovery) |
|
||||
| RELEASE_DUR | 0.40–0.90 s | shorter = tight pop; longer = loose, wobbly settle |
|
||||
| BOUNCE_FACTOR | 1.4 soft · 2.0 firm · 2.8 cartoony | or `elastic.out(amplitude, period)` for a rubbery oscillation instead of one overshoot |
|
||||
| RELEASE_START | `= PRESS_START + PRESS_DUR` | adjacency = automatic state continuity |
|
||||
| BURST_PEAK_SCALE | 3 subtle · 6 default · 8 max | beyond ~8 the radial gradient pixelates visibly |
|
||||
| BURST_PEAK_OPACITY | 0.4–1.0 | grow ≈ fade, 0.4–0.7 s each; blur 40–100 px (hard ring → ambient haze) |
|
||||
| BG_GLOW_PEAK_OPACITY | 0.1 subtle · 0.25 default · 0.45 max | higher washes the whole composition; fade-in 0.6–1.0 s; inset −300…−500 px at 1080p |
|
||||
|
||||
Color tokens: pressed surface darker than rest; rest shadow large + diffuse, pressed small + tight (the button "sinks toward the surface"); burst gradient darker + more saturated than `{btnBg}` — same-color glow looks washed out; bg glow a low-opacity tint of the button's hue family.
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **State continuity** — release start value exactly equals press end value; enforced by same-property adjacency at `RELEASE_START = PRESS_START + PRESS_DUR`.
|
||||
- **Linear press, spring release** — both spring → squishy; both linear → mechanical, no overshoot punch.
|
||||
- **Anchor compression on center** (`transform-origin: 50% 50%`) or the button collapses asymmetrically.
|
||||
- **Burst behind, not in front** — burst `z-index: 1`, button `z-index: 2`; in front it occludes the button at peak opacity.
|
||||
- **Don't tween `boxShadow` and `filter` on the same element** — they compete in the layout pipeline; shadow on the button, blur on the separate burst layer.
|
||||
- **Climax dwell** — after the burst peak + reveal, the composition must run ≥ 1 s more (≥ 2 s for dramatic variants); a reveal at `t = DURATION − 0.2 s` reads as "flashed and gone."
|
||||
|
||||
## See also
|
||||
|
||||
`spring-pop-entrance` (the ENTRANCE counterpart — arrival, not reaction) · `physics-press-reaction` (this press with a visible cursor actor) · `cursor-click-ripple` (the cursor click that triggers the press) · `sine-wave-loop` (idle micro-float BEFORE the press) · `center-outward-expansion` (badge burst synced to the release).
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
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 — a causal link: "A moves _because_ B hit it." Distinct from [scale-swap-transition.md](scale-swap-transition.md) (which overlaps but isn't causal) and [card-morph-anchor.md](card-morph-anchor.md) (one container morphing).
|
||||
|
||||
A single 0→1 driver tween (the "entry spring") feeds three concurrent derived motions in one `onUpdate`:
|
||||
|
||||
- **Intruder** (B, entering): position interpolated off-stage → settled over the full driver, plus tilt settling to 0° and a sharp early opacity reveal.
|
||||
- **Victim** (A, exiting): position interpolated settled → off-stage in the OPPOSITE direction, completing at `VICTIM_FRACTION` (~0.4–0.5) of the driver — NOT 1.0.
|
||||
|
||||
The victim finishing BEFORE the intruder's entry creates the "hit then settle" rhythm; sharing one eased driver makes the impact moment mathematically synchronized.
|
||||
|
||||
## Recipe
|
||||
|
||||
```js
|
||||
// Both cards absolutely centered; overflow: hidden on the scene (off-stage travel);
|
||||
// will-change: transform, opacity on both; intruder z-index ABOVE victim.
|
||||
const INTRUDER_START_X = STAGE_W; // off-stage right
|
||||
const VICTIM_END_X = -STAGE_W; // off-stage left — SAME axis, opposite direction
|
||||
|
||||
gsap.set("#victim", { x: 0, opacity: 1, rotation: 0 });
|
||||
gsap.set("#intruder", { x: INTRUDER_START_X, opacity: 0, rotation: -INTRUDER_TILT });
|
||||
|
||||
const driver = { p: 0 };
|
||||
tl.to(
|
||||
driver,
|
||||
{
|
||||
p: 1,
|
||||
duration: DRIVER_DUR,
|
||||
ease: `back.out(${BOUNCE_FACTOR})`, // the intruder spring
|
||||
onUpdate: () => {
|
||||
// Intruder: full 0→1 progress maps 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); // settles 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 its exit at VICTIM_FRACTION of the driver — by the
|
||||
// time the intruder centers, the victim is already off-stage.
|
||||
const victimP = Math.min(1, driver.p / VICTIM_FRACTION);
|
||||
const victimX = VICTIM_END_X * victimP;
|
||||
const victim = document.getElementById("victim");
|
||||
victim.style.transform = `translate(-50%, -50%) translateX(${victimX}px)`;
|
||||
victim.style.opacity = String(1 - victimP);
|
||||
},
|
||||
},
|
||||
DRIVER_AT,
|
||||
);
|
||||
// Climax dwell — intruder holds centered for ≥ DWELL_MIN before the scene ends.
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Impact rotation on victim** — the victim also rotates as it slides: `const victimRot = victimP * -VICTIM_KICK_DEG;` appended to its transform. `VICTIM_KICK_DEG` 15–25°, magnitude matched to the perceived intruder weight.
|
||||
- **Vertical collision** — intruder from top, victim displaced downward; same math on Y. Reads as "weight dropped on it."
|
||||
- **Wobble after settle** — after the intruder centers, a damped sine wobble (`±WOBBLE_AMP_DEG` rotation, linearly decaying over `WOBBLE_DUR` via a second `ease: "none"` driver at `DRIVER_AT + DRIVER_DUR`) before stillness — "impact aftermath."
|
||||
- **Multi-victim ripple** — the intruder displaces multiple aligned cards, each victim's `victimP` on a slightly offset driver phase (cascade ripple).
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| ----------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| DRIVER_AT | phase-dependent | after the prior reading beat resolves; must leave ≥ DWELL_MIN of climax dwell before the scene ends |
|
||||
| DRIVER_DUR | 0.6–1.4 s | short = zippy punch, long = heavy landed impact; higher bounce on long durations reads as floaty |
|
||||
| BOUNCE_FACTOR | 1.2–2.0 (typ. 1.4–1.6) | stay in the `back.out` family (or `elastic.out` for oscillation) — changing family rewrites the feel |
|
||||
| VICTIM_FRACTION | 0.4–0.5 | <0.4 the victim disappears before the impact reads; >0.5 feels parallel, not causal; hard cap ~0.6 |
|
||||
| STAGE_W | ≥ composition width | smaller leaves the off-stage element partially visible at start |
|
||||
| INTRUDER_TILT | 5–15° (typ. ~10°) | low = clean glide, high = "spin-and-plant"; sign consistent with entry direction (momentum transfer) |
|
||||
| FADE_IN_SHARPNESS | 3–8 | intruder reaches opacity 1 at `1/FADE_IN_SHARPNESS` of progress; must be > 1 or it's transparent at center |
|
||||
| DWELL_MIN | ≥ 1.0 s (typ. 1.0–1.5) | post-impact dwell is where the new content gets read — do not skip |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Single driver = single source of truth** — both motions computed inside ONE driver's `onUpdate`, never separate `tl.to()` calls per element; independent tweens destroy the causal link (they'd merely be near each other in time).
|
||||
- **Victim completes at a fraction of the driver** — the "hit" is the overlap moment; after it the victim is just vacating space the intruder will fill.
|
||||
- **Directional momentum transfer** — same axis, opposite directions; different axes read as passing, not colliding.
|
||||
- **Intruder z-index above victim** — explicit, not DOM order; otherwise the victim looks like it tunneled through.
|
||||
- **Intruder enters tilted, settles flat** — small initial tilt → 0° reads as "spinning in then planting."
|
||||
- **Climax dwell after impact** — the impact is the headline beat; hold the settled intruder ≥ DWELL_MIN.
|
||||
- **`overflow: hidden` on the scene** — off-stage motion exceeds the frame.
|
||||
|
||||
## See also
|
||||
|
||||
`control-target-sync` (the live-editing mirror — repeated coupled edits, nothing exits) · `hacker-flip-3d` (intruder text reveal during entry) · `sine-wave-loop` (idle breathing during the dwell) · `vertical-spring-ticker` (a ticker that "shoves" the previous content out).
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
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.md](card-morph-anchor.md) (which morphs container dimensions — use that for SHAPE changes; this rule is for SAME-shape state swaps) and easier than SVG path interpolation.
|
||||
|
||||
At a single trigger, two coordinated tweens fire:
|
||||
|
||||
1. **Outgoing**: scale `1.0 → EXIT_SCALE` + opacity `1 → 0`, fast `power2.in` (rushing away).
|
||||
2. **Incoming**: scale `EXIT_SCALE → 1.0` + opacity `0 → 1`, `back.out(BOUNCE_FACTOR)` (arriving with weight).
|
||||
|
||||
A small `OVERLAP` window during which both are mid-tween creates the morph illusion; the incoming sits on top via z-index so the outgoing's fade-tail doesn't bleed through.
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<!-- Both cards position: absolute; inset: 0 in one fixed-size wrapper — same
|
||||
footprint, same transform-origin: 50% 50%. Incoming starts opacity: 0,
|
||||
transform: scale(EXIT_SCALE), z-index above the outgoing. -->
|
||||
<div class="swap-wrap">
|
||||
<div class="card outgoing" id="outgoing">{outgoingIcon} {outgoingLabel}</div>
|
||||
<div class="card incoming" id="incoming">
|
||||
{incomingIcon} {incomingLabel}
|
||||
<div class="sub" id="sub">{incomingSubline}</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
```js
|
||||
// Outgoing: shrink + fade fast
|
||||
tl.to(
|
||||
"#outgoing",
|
||||
{ scale: EXIT_SCALE, opacity: 0, duration: EXIT_DUR, ease: "power2.in" },
|
||||
TRIGGER,
|
||||
);
|
||||
|
||||
// Incoming: pops in with overshoot, starting OVERLAP before the exit finishes
|
||||
tl.to(
|
||||
"#incoming",
|
||||
{ scale: 1.0, opacity: 1, duration: ENTER_DUR, ease: `back.out(${BOUNCE_FACTOR})` },
|
||||
TRIGGER + EXIT_DUR - OVERLAP,
|
||||
);
|
||||
|
||||
// Inner content reveals AFTER the incoming 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,
|
||||
);
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Delayed inner content reveal** — the classic pattern above: morph the container, then reveal inner text once it settles; the 0.2–0.4 s gap lets the eye land on the new shape before reading.
|
||||
- **Triple swap (3-state cycle)** — chain A→B→C with triggers `TRIGGER_AB` / `TRIGGER_BC`; each transition is its own tween pair, the previous incoming becoming the next outgoing. State-evolution narratives (early → mid → final labels).
|
||||
- **Color-shift transition (no scale)** — for a flat morph between same-shape states, drop the scale and keep opacity + a brief background hue tween; less dramatic, more product-UI tone.
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| ---------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| TRIGGER | ≥ outgoing settled + a presence-dwell | the outgoing must "land" before transforming |
|
||||
| EXIT_DUR | 0.3–0.5 s | |
|
||||
| ENTER_DUR | 0.45–0.7 s | longer than `EXIT_DUR` so the overshoot can settle |
|
||||
| OVERLAP | 0.1–0.2 s | >0.3 s both are clearly visible together (no morph); <0.05 s leaves a visible empty gap |
|
||||
| EXIT_SCALE | 0.6–0.8 | smaller exits feel dramatic but risk reading as "vanish" instead of "morph" |
|
||||
| BOUNCE_FACTOR | 1.4 soft · 1.8 firm · 2.2 cartoony | |
|
||||
| SUB_REVEAL_DELAY | 0.2–0.4 s | reveals during the morph compete with the swap for attention |
|
||||
| BRAND_REVEAL_AT | < TRIGGER | context (brand, eyebrow) sets the stage early; revealed AT the swap it competes with the headline beat |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Incoming z-index ABOVE outgoing** — otherwise the outgoing's fade-tail (opacity 0.3–0.5) bleeds through and double-exposes the frame.
|
||||
- **Both elements share `transform-origin: 50% 50%`** — different origins make the morph read as one thing teleporting elsewhere.
|
||||
- **Bouncy ease ONLY on the incoming** — outgoing `power2.in`, incoming `back.out`; reversed, the swap feels mechanical.
|
||||
- **Both cards `position: absolute; inset: 0`** in the same fixed-size wrapper (sized to fit both states; the wrap never resizes).
|
||||
- **Don't `display: none` the outgoing** after the fade — leave it at `opacity: 0` so layout doesn't reflow.
|
||||
- **Inner content reveals after the container settles**; **climax dwell ≥ 1 s** after the final state + subline land.
|
||||
|
||||
## See also
|
||||
|
||||
`press-release-spring` (a button press TRIGGERS the swap — cause and effect) · `card-morph-anchor` (shape-changing alternative) · `reactive-displacement` (when the replacement should read as a causal collision) · `sine-wave-loop` (idle breathing on the final state).
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
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." First fill the back of a shot with **sequential reveal timed to the VO**; if a frame has genuinely settled and still needs life, the **sanctioned move is subtle jitter** — this rule at the LOW end of its amplitude range. A full breathing loop is the rare last resort on a single held hero, never stamped on every element.
|
||||
|
||||
Keeps a settled element from feeling dead using `Math.sin` on the timeline clock. Two forms:
|
||||
|
||||
- **Yoyo form** — one `sine.inOut` tween with `yoyo: true` and a **finite** `repeat` count. Preferred when the idle stands alone on a property nothing else touches.
|
||||
- **onUpdate form** — one long `ease: "none"` tween drives a `phase` proxy `0 → 2π·CYCLES`; `onUpdate` maps `Math.sin(phase)` into the transform. Required when the offset multiplies/adds onto another live value (compound transforms, amplitude envelopes, multi-octave).
|
||||
|
||||
Either way, idle begins where the entry settled: at `phase = 0`, `sin(0) = 0` — the offset is zero, so there is no jump from the entry's resting state.
|
||||
|
||||
## Recipe
|
||||
|
||||
```js
|
||||
// onUpdate form — phase-driven, composable.
|
||||
const phase = { p: 0 };
|
||||
tl.to(
|
||||
phase,
|
||||
{
|
||||
p: Math.PI * 2 * CYCLES,
|
||||
duration: IDLE_DUR,
|
||||
ease: "none", // sine provides the easing; a non-linear phase tween distorts the wave
|
||||
onUpdate: () => {
|
||||
const s = Math.sin(phase.p);
|
||||
hero.style.transform = `translateY(${s * Y_AMP_PX}px) scale(${1 + s * SCALE_AMP})`;
|
||||
// secondary elements: offset by Math.PI / 2 — synced motion looks mechanical
|
||||
dot.style.transform = `scale(${1 + Math.sin(phase.p + Math.PI / 2) * DOT_SCALE_AMP})`;
|
||||
},
|
||||
},
|
||||
IDLE_START_TIME,
|
||||
);
|
||||
|
||||
// Yoyo form — standalone property, finite repeats.
|
||||
tl.to(
|
||||
"#badge",
|
||||
{ y: -Y_AMP_PX, duration: PERIOD / 2, ease: "sine.inOut", yoyo: true, repeat: REPEATS },
|
||||
IDLE_START_TIME,
|
||||
);
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Multi-octave** (organic): stack a higher-frequency overlay — `1 + Math.sin(p) * AMP_PRIMARY + Math.sin(p * OCTAVE_RATIO) * AMP_SECONDARY`, with `AMP_SECONDARY < AMP_PRIMARY` and the combined max inside the normal SCALE_AMP range.
|
||||
- **Settle and fade** (strongly recommended when `IDLE_DUR > 6s`): ramp amplitude to zero over the last ~20% of idle so the scene visibly settles before the inter-scene transition, instead of handing off mid-drift:
|
||||
|
||||
```js
|
||||
const t = phase.p / (Math.PI * 2 * CYCLES); // 0 → 1 across idle
|
||||
const env = t < 1 - FADE_FRAC ? 1 : (1 - t) / FADE_FRAC; // FADE_FRAC ≈ 0.2
|
||||
const scale = 1 + Math.sin(phase.p) * SCALE_AMP * env;
|
||||
```
|
||||
|
||||
This is the single biggest fix when finalize snapshots show "everything's still moving at the end"; it pairs naturally with break-boundary transitions (the outgoing visual is static when the crossfade/push begins).
|
||||
|
||||
## Values
|
||||
|
||||
| token | range / default | notes |
|
||||
| --------------- | ------------------------------------ | -------------------------------------------------------------------------- |
|
||||
| SCALE_AMP | **0.008–0.015 default** | push to 0.02–0.04 only when isolated on canvas / scene <6s / kinetic brief |
|
||||
| Y_AMP_PX | **2–3px default** | 4–6px only under the same gating; rotation ±0.3–0.8° rarely needed at all |
|
||||
| period | 1.5–3s (2.5–4s when idle is long) | <1.5s frantic; >4s lifeless in a short window |
|
||||
| CYCLES | `IDLE_DUR/3 ≤ CYCLES ≤ IDLE_DUR/1.5` | derive from the period, not the other way round |
|
||||
| IDLE_START_TIME | ≥ entry settle + ~0.1s | `sin(0)=0` at this moment → no jump off the entry tail |
|
||||
| IDLE_DUR | `TOTAL_DURATION − IDLE_START_TIME` | one long tween fills the hold — never restarted |
|
||||
| DOT_SCALE_AMP | 0.04–0.12 | small accents tolerate more than the hero |
|
||||
| OCTAVE_RATIO | 2.0–4.0 | integer-ish reads musical; non-integer reads organic |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Prefer reveal, then jitter, then breath** — the doctrine order above; default to the LOW end of every amplitude range. At the upper end across 5+ consecutive scenes the whole film reads as "shimmering".
|
||||
- **Long idle window** (`IDLE_DUR > 6s` OR idle > 30% of composition): halve `SCALE_AMP` / `Y_AMP_PX`, slow the period to 3–4s, and add the settle-and-fade tail.
|
||||
- **Concurrent idle on N elements** (columns, card grid, stat row): per-element amplitude ≤ default `/ √N`, AND stagger the periods (2.1s / 1.9s / 2.4s). Three columns at ±6px compound to ±18px of competing motion; three at ±2–3px read as one collective breath.
|
||||
- **Compose, don't replace** — idle ADDS to the element's resting transform; never overwrite the entry's final translation.
|
||||
- **Phase tween `ease: "none"`** — sine itself is the curve.
|
||||
- **No CSS `@keyframes` for idle** — CSS animation runs on the browser's render clock, independent of the HF seek clock; a CSS-driven idle flickers/desyncs. Drive idle inside the timeline.
|
||||
|
||||
## See also
|
||||
|
||||
`ambient-glow-bloom` (the glow-layer counterpart, same bounded-breathe discipline) · `press-release-spring` / `counting-dynamic-scale` / `card-morph-anchor` / `orbit-3d-entry` (settled elements this can follow) · `spring-pop-entrance` (the arrival that precedes any idle).
|
||||
@@ -0,0 +1,123 @@
|
||||
---
|
||||
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 side-by-side with opposing `rotateY` (left `+TILT`, right `−TILT`) — a symmetric "book-open" 3D split for comparisons, before/after, feature pairs. Each card slides in from its own side (reinforcing "they came from their own worlds and met here"), then the pair idles in counter-phase.
|
||||
|
||||
## How It Works
|
||||
|
||||
`perspective` on the scene root (REQUIRED — without it `rotateY` flattens to a 2D layout) and `transform-style: preserve-3d` on the stage and both cards. Entry starts each card off-axis with `TILT + TILT_OVERSHOOT`, settling to `TILT` — a pivot-into-place. Idle is a gentle counter-phase y-bob (the two yoyo tweens run in opposite directions); copy fades up during the cards' settle, not after.
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<!-- inside a standard scene clip (hyperframes-core) -->
|
||||
<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>
|
||||
</div>
|
||||
```
|
||||
|
||||
```css
|
||||
.scene-root {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
perspective: SCENE_PERSPECTIVE; /* REQUIRED */
|
||||
}
|
||||
.split-stage {
|
||||
display: flex;
|
||||
gap: STAGE_GAP;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
.card {
|
||||
width: CARD_WIDTH;
|
||||
transform-style: preserve-3d;
|
||||
will-change: transform;
|
||||
}
|
||||
/* Shadow falls WITH the facing direction: left card faces right → shadow right. */
|
||||
.card-left {
|
||||
box-shadow: -CARD_SHADOW_OFFSET CARD_SHADOW_DROP CARD_SHADOW_BLUR {shadowColor};
|
||||
}
|
||||
.card-right {
|
||||
box-shadow: CARD_SHADOW_OFFSET CARD_SHADOW_DROP CARD_SHADOW_BLUR {shadowColor};
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// Entry — from outside, opposing tilts settle with a small pivot
|
||||
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,
|
||||
);
|
||||
|
||||
// Counter-phase idle bob — opposite signs = alive; synchronized = conveyor belt
|
||||
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,
|
||||
);
|
||||
|
||||
// Copy fades up during the settle
|
||||
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,
|
||||
);
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Badges / floating labels**: position them on the PARENT, never inside a card — inside they inherit the `rotateY` and tilt off-axis.
|
||||
- **3+ cards**: center card stays flat (`rotateY: 0`), outer two tilt inward — "old way / nothing / our way."
|
||||
- **Zoom-through**: a separate camera tween scaling `.split-stage` reads as the viewer crossing the gap between the tilted pair.
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| ----------------- | -------------------------------- | ------------------------------------------------------- |
|
||||
| SCENE_PERSPECTIVE | 1000–2400px | lower exaggerates the tilt; higher reads near-isometric |
|
||||
| TILT | 10–18° | < 10 reads almost flat; > 18 folds shut and copy blurs |
|
||||
| TILT_OVERSHOOT | 4–12° | the pivot-into-place feel |
|
||||
| STAGE_GAP | 40–120px (~0.06–0.15×CARD_WIDTH) | small = fused pair; large = compared-but-separate |
|
||||
| CARD_WIDTH | 480–820px @1920 | `2×CARD_WIDTH + STAGE_GAP ≤ 0.95×stage` at full tilt |
|
||||
| ENTRY_SLIDE_DIST | 200–500px (~0.3–0.6×CARD_WIDTH) | |
|
||||
| ENTRY_DUR | 0.6–1.2s | |
|
||||
| RIGHT_AT | LEFT_AT + 0–0.3s | zero feels mechanical; large fragments the pair |
|
||||
| FLOAT_AMP | 3–8px | subtle is the point |
|
||||
| FLOAT_DURATION | 1.6–3.2s round trip | breathing cadence; IDLE_START ≥ entry end |
|
||||
| COPY_REVEAL_AT | during the entry tail | copy popping in after cards are idle reads disconnected |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **`perspective` on the scene root is REQUIRED**; `preserve-3d` on the stage AND each card.
|
||||
- **Shadow direction matches tilt** — left card faces right → shadow falls right (and mirrored). Wrong sign reads as broken 3D.
|
||||
- **Counter-phase idle** — the two bobs run with opposite signs at the same position.
|
||||
- **Badges outside the card divs** (they'd inherit the rotation).
|
||||
- **Body copy ≤ 2 lines per card** — tilted long paragraphs collapse into perspective blur.
|
||||
- **Symmetric weight** — same width, same vertical center, similar line counts; asymmetry breaks the comparison metaphor.
|
||||
|
||||
## See also
|
||||
|
||||
`card-morph-anchor` (the pair can morph into one unified shape afterward) · `counting-dynamic-scale` (numbers as each side's headline) · `sine-wave-loop` (the idle form).
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
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.** This entrance defaults to a smooth long-tail settle — `power3.out` (or `expo.out` for a faster front) — that decelerates cleanly into the resting size with **no overshoot**. Bouncy `back.out` is the **#1 instant turn-off** in agent-made videos and is almost never executed well; it is a rare, explicitly-playful exception (consumer / fun brand), never the default. When unsure, settle smoothly.
|
||||
|
||||
THE entrance primitive: an element (or staggered group) arrives by springing from nothing — `scale: 0 → 1`, optional small `y` rise — and settles without bouncing. This is **arrival**, not reaction: distinct from [press-release-spring.md](press-release-spring.md) (a click/press → release feedback chain on an element that already rests on screen). Many blueprints used to borrow that rule to fake an entrance; reach for this instead.
|
||||
|
||||
## How It Works
|
||||
|
||||
One `fromTo` carries the whole arrival: from `{ scale: 0, opacity: 0 }` (explicit, so t=0 is correct under seek) to `{ scale: 1, opacity: 1, ease: "power3.out" }`. For a **group**, the same `fromTo` runs per element at `i * STAGGER`, capped so the group reads as one arriving beat. The `scale` grow is load-bearing; the `y` rise is garnish — drop everything else and it must still read as a clean entrance. Let the ease produce the settle: never hand-key a `scale: 1.1` mid-state (it double-bounces against the curve).
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<!-- inside a standard scene clip (hyperframes-core) -->
|
||||
<div class="pop-hero" id="hero">{heroLabel}</div>
|
||||
|
||||
<div class="pop-grid">
|
||||
<div class="pop-item">{itemA}</div>
|
||||
<div class="pop-item">{itemB}</div>
|
||||
<div class="pop-item">{itemC}</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
```css
|
||||
.pop-hero,
|
||||
.pop-item {
|
||||
transform-origin: 50% 50%; /* in-place pop; move to the source point for the anchored variation */
|
||||
will-change: transform;
|
||||
}
|
||||
.pop-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: GRID_GAP;
|
||||
place-items: center;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// Single hero pop — smooth long-tail settle, no overshoot.
|
||||
tl.fromTo(
|
||||
"#hero",
|
||||
{ scale: 0, opacity: 0 },
|
||||
{ scale: 1, opacity: 1, duration: POP_DUR, ease: "power3.out" },
|
||||
ENTRY_AT,
|
||||
);
|
||||
|
||||
// Staggered group pop — one arriving beat.
|
||||
gsap.utils.toArray(".pop-item").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,
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Calm settle** (premium / enterprise): `power3.out`, no rotation, `Y_RISE` 0–12px — a weighted, confident landing for a hero wordmark or product shot.
|
||||
- **Firm settle** (everyday default): `power3.out` or `expo.out` for a punchier front, `Y_RISE` ~24px — cards, icons, callouts.
|
||||
- **Exact-physics settle**: when the settle IS the shot, swap the ease for `springEase({ response: 0.4 })` (critically damped) from `../adapters/gsap-easing-and-stagger.md` → Spring Eases; take `duration` from the helper.
|
||||
- **Origin-anchored pop**: a callout growing out of a specific point (marker, pointer tip) sets `transform-origin` to that point (e.g. `0% 100%`) so `scale: 0 → 1` reads as "emerging from the source", not "inflating in place".
|
||||
- **Pop into a held slot**: land the pop and hold still — no idle loop baked into the entrance. If the held frame genuinely needs life, hand off to [sine-wave-loop.md](sine-wave-loop.md) for subtle jitter on a separate later tween; prefer revealing the next element on its VO cue.
|
||||
- **Bouncy pop (RARE — explicitly-playful only)**: swap the ease for `back.out(OVERSHOOT)` and optionally settle a small `rotation: ROT_FROM → 0` so elements look hand-placed. Only for a deliberately playful register — never product / enterprise / serious tone:
|
||||
|
||||
```js
|
||||
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,
|
||||
);
|
||||
```
|
||||
|
||||
Even here keep `OVERSHOOT ≤ ~2` — past that it reads as cartoon wobble. Better still: the baked spring at `dampingFraction: 0.6–0.7` (same adapters doc) gives ~5–10% overshoot that reads physical where `back.out` reads cartoon.
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| ---------- | ----------------------------------------- | ---------------------------------------------------------------- |
|
||||
| EASE | `power3.out` default; `expo.out` punchier | `back.out(OVERSHOOT)` only in the playful variant |
|
||||
| POP_DUR | 0.4–0.7s | shorter = tight snap; hero must be visible by **t ≤ 0.5s** |
|
||||
| STAGGER | 0.04–0.08s | `min(0.06, 0.5 / ITEM_COUNT)` — self-caps the window |
|
||||
| ITEM_COUNT | 3–9 | >9 makes the stagger vanish — switch to a wipe/sweep reveal |
|
||||
| Y_RISE | 0–32px | small; never large enough to read as a slide-up |
|
||||
| ROT_FROM | −10°–+10° | playful variant only; alternate sign by index (`i % 2 ? 6 : -6`) |
|
||||
| ENTRY_AT | 0–0.4s | a beat of quiet, but keep the subject landing by t ≤ 0.5s |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- Default ease `power3.out` (no overshoot); `back.out` only in the explicitly-playful variant, and there `OVERSHOOT ≤ ~2`.
|
||||
- `ITEM_COUNT × STAGGER ≤ ~0.5s` — the group must land inside one beat.
|
||||
- Entrances state the collapsed from-state in `fromTo` — never rely on a CSS-hidden start (it renders visible before the tween claims it under seek).
|
||||
- `transform-origin: 50% 50%` for an in-place pop; the source point only for the anchored variation.
|
||||
- This is a finite arrival — idle motion on a held element is a separate, later `sine-wave-loop` tween.
|
||||
|
||||
## See also
|
||||
|
||||
`center-outward-expansion` (pop while radiating to slots) · `press-release-spring` (the click-feedback counterpart) · `sine-wave-loop` (post-arrival jitter, sparingly).
|
||||
@@ -0,0 +1,145 @@
|
||||
---
|
||||
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.
|
||||
|
||||
## Recipe
|
||||
|
||||
### 1 — Growth Bars (CSS `scaleY` stagger)
|
||||
|
||||
Bars grow from the baseline with a stagger; the last bar is the accent. Heights are authored in CSS (inline height per bar); GSAP only reveals `scaleY: 0 → 1` — never animate `height`.
|
||||
|
||||
```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
|
||||
tl.to(".bar", { scaleY: 1, duration: 0.7, ease: "power3.out", stagger: 0.08 }, 0.3);
|
||||
```
|
||||
|
||||
### 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 (automated gates may miss 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 (mechanics in [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,
|
||||
);
|
||||
```
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| ------------- | ----------- | ----------------------------------------------------------------------------------- |
|
||||
| bar count | 4–6 | reads as "a trend" without clutter; the last bar is the current/accent value |
|
||||
| fill duration | 0.8–1.2s | matched to the paired count-up so number and graphic land together (share the ease) |
|
||||
| stagger | 0.06–0.1s | larger feels sluggish, 0 loses the build |
|
||||
| accent hue | exactly one | bars/fill/stars all use the same accent, the rest is muted |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **`scaleY` / `scaleX` / `clipPath`, never `height`/`width` tweens** — author each bar's final height in CSS and scale from 0.
|
||||
- **`transform-origin`** must be `bottom` (bars grow up) / `left` (fills grow right) — the default center origin scales from the middle and looks wrong.
|
||||
- **`.fill` needs `width: 100%`** — a zero-width fill scaled by any factor is still invisible, and automated gates may miss it.
|
||||
- **Measure, don't hard-code** — ring length via `getTotalLength()`; a hard-coded circumference breaks if the radius changes.
|
||||
- **Match the number's timing** — the fill and the count-up peak together (same start + ease) so the stat resolves as one beat, not two; a paired counter's `onUpdate` must be O(1) (see [counting-dynamic-scale.md](counting-dynamic-scale.md)).
|
||||
- **One accent hue, consistent blueprint** — see `hyperframes-creative/references/data-in-motion.md`.
|
||||
|
||||
## See also
|
||||
|
||||
`counting-dynamic-scale` (the number beside the graphic — same ease/duration) · `svg-path-draw` (progress-ring draw mechanics) · `hyperframes-creative/references/data-in-motion.md` (stat layout + visual weight).
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
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 micro-animation, targeted by id. Distinct from [svg-path-draw](svg-path-draw.md) (which animates the OUTLINE drawing) — enrichment animates INTERNAL PARTS, ideally after the outline has drawn.
|
||||
|
||||
Four signature patterns:
|
||||
|
||||
| Pattern | Use For | Math | Tip |
|
||||
| ----------- | ---------------------------------- | ------------------------------------- | ---------------------------------- |
|
||||
| Rotation | Clock, gear, loader, dial | `rotate(deg cx cy)` attribute, linear | see the transform-center gotcha |
|
||||
| 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 |
|
||||
|
||||
## ❗ The transform-center gotcha
|
||||
|
||||
**For rotation around an explicit point inside an 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 viewBox coordinates. For a thin `<line>` (whose bbox is the line's narrow envelope), `60 60` bbox-local is a point OUTSIDE the line — the hand flies along an off-center arc instead of rotating in place. Same trap for small inner shapes (a dot circle whose bbox is the small circle, not the full viewBox).
|
||||
|
||||
**Scaling around a center point**: same attribute route — `el.setAttribute("transform", `translate(${cx} ${cy}) scale(${s}) translate(-${cx} -${cy})`)`.
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<!-- inside a standard scene clip — named children are the animation targets -->
|
||||
<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
|
||||
id="hand-min"
|
||||
x1="60"
|
||||
y1="60"
|
||||
x2="60"
|
||||
y2="22"
|
||||
stroke="{textColor}"
|
||||
stroke-width="6"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<line
|
||||
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>
|
||||
<!-- pulse icon: #rec-ring + #rec-dot circles; dash-flow: a <line> with stroke-dasharray="14 12" -->
|
||||
```
|
||||
|
||||
```js
|
||||
// Pattern 1 — Rotation. Proxy tween → SVG transform attribute (explicit center, see gotcha).
|
||||
const hand = document.getElementById("hand-min");
|
||||
const minState = { deg: 0 };
|
||||
tl.to(
|
||||
minState,
|
||||
{
|
||||
deg: 360 * MIN_REVOLUTIONS,
|
||||
duration: TOTAL_DURATION,
|
||||
ease: "none", // linear motion is the point
|
||||
onUpdate: () => hand.setAttribute("transform", `rotate(${minState.deg} 60 60)`),
|
||||
},
|
||||
0,
|
||||
);
|
||||
// second hand: same shape with SEC_REVOLUTIONS (visibly faster).
|
||||
|
||||
// Pattern 3 — Pulse. One phase proxy drives dot + ring, ring offset by π/2.
|
||||
const dot = document.getElementById("rec-dot");
|
||||
const ring = document.getElementById("rec-ring");
|
||||
const pulse = { p: 0 };
|
||||
tl.to(
|
||||
pulse,
|
||||
{
|
||||
p: Math.PI * 2 * PULSE_CYCLES,
|
||||
duration: TOTAL_DURATION,
|
||||
ease: "none", // sine handles the curve
|
||||
onUpdate: () => {
|
||||
const sD = 1 + Math.sin(pulse.p) * PULSE_DOT_AMP;
|
||||
const sR = 1 + Math.sin(pulse.p + Math.PI / 2) * PULSE_RING_AMP;
|
||||
dot.setAttribute("transform", `translate(60 60) scale(${sD}) translate(-60 -60)`);
|
||||
ring.setAttribute("transform", `translate(60 60) scale(${sR}) translate(-60 -60)`);
|
||||
ring.style.opacity = String(
|
||||
PULSE_RING_OPACITY_BASE + Math.sin(pulse.p) * PULSE_RING_OPACITY_AMP,
|
||||
);
|
||||
},
|
||||
},
|
||||
0,
|
||||
);
|
||||
|
||||
// Pattern 4 — Dash flow. Linear offset tween on a dashed stroke.
|
||||
const flowState = { offset: 0 };
|
||||
tl.to(
|
||||
flowState,
|
||||
{
|
||||
offset: DASH_FLOW_TOTAL_OFFSET, // negative = L→R
|
||||
duration: TOTAL_DURATION,
|
||||
ease: "none",
|
||||
onUpdate: () => {
|
||||
document.getElementById("data-flow").style.strokeDashoffset = String(flowState.offset);
|
||||
},
|
||||
},
|
||||
0,
|
||||
);
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Stroke draw → enrichment chain** — draw the outline first via [svg-path-draw](svg-path-draw.md) (phase 1, `0 → OUTLINE_DUR`), then start enrichment at `OUTLINE_DUR`: the icon "wakes up" after assembly.
|
||||
- **Per-icon entry stagger** — for a row of icons, each icon's enrichment starts as it fades in, not synchronized.
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| ------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| MIN_REVOLUTIONS | 0.5–2.0 | avoid integer revolutions if the end frame is visible (lands back at start) |
|
||||
| SEC_REVOLUTIONS | 4–10 | > MIN × 3 or the speed difference doesn't read |
|
||||
| PULSE_CYCLES | 2–4 over a 3–5s comp | ≥5 reads as anxious flicker; ≤1 reads as forgotten |
|
||||
| PULSE_DOT_AMP | 0.05–0.20 | 0.05 = breathing; 0.20 = throbbing |
|
||||
| PULSE_RING_AMP | 0.04–0.12 | must be < PULSE_DOT_AMP or the ring overshadows the dot |
|
||||
| PULSE_RING_OPACITY_BASE / \_AMP | 0.4–0.6 / 0.3–0.5 | BASE − AMP ≥ 0 and BASE + AMP ≤ 1 |
|
||||
| DASH_FLOW_TOTAL_OFFSET | ±100–400 | must be an integer multiple of the dash period (dash + gap) or the end frame shows a phase jump |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **The transform-center gotcha above** — SVG `transform` attribute for any rotation/scale around an explicit interior point; never CSS `transform-origin` + `transform-box: fill-box` on thin lines or small inner shapes.
|
||||
- **No `requestAnimationFrame`** — like CSS animation, it desyncs from HF's frame-by-frame seek; continuous motion lives inside the timeline as linear proxy tweens.
|
||||
- **Amplitudes subtle** — icons are decorative, not headlines; calibrate rotation speed against composition length, not absolute time.
|
||||
- **Phase-offset the parts** — minute vs second hand at different speeds, ring lagging dot by π/2. Pure sync looks mechanical.
|
||||
- **`stroke-linecap: round`** on flowing/dashed lines for clean dash edges.
|
||||
- **Climax dwell ≥1s** — if the enrichment is the headline beat, the composition continues ≥1s after the most dramatic moment.
|
||||
|
||||
## See also
|
||||
|
||||
`svg-path-draw` (outline draws first, enrichment second) · `orbit-3d-entry` (orbiting items are enriched icons) · `sine-wave-loop` (the whole icon floats while internal parts animate).
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
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. Two stroke properties together: **`stroke-dasharray = <pathLength>`** makes the entire path one dash; **`stroke-dashoffset`** starts at the path length (dash shifted fully out of view → invisible) and tweens to `0` (fully drawn). The length comes from the DOM API `path.getTotalLength()` — measured, never guessed.
|
||||
|
||||
Works on anything with a stroke: `<path>`, `<circle>`, `<rect>`, `<line>`, `<polyline>`, `<polygon>`, `<ellipse>`.
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<!-- inside a standard scene clip -->
|
||||
<svg class="logo-mark" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<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>
|
||||
```
|
||||
|
||||
```css
|
||||
.logo-mark path {
|
||||
fill: none; /* outline-only draw — a fill would appear immediately and ruin the reveal */
|
||||
stroke: {accentColor};
|
||||
stroke-width: 12;
|
||||
stroke-linecap: round; /* softer endpoints */
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
// Setup: measure each path and set its dash pattern. Real measured geometry, not a magic number.
|
||||
document.querySelectorAll(".logo-mark path").forEach((p) => {
|
||||
const len = p.getTotalLength();
|
||||
p.style.strokeDasharray = `${len}`;
|
||||
p.style.strokeDashoffset = `${len}`;
|
||||
});
|
||||
|
||||
// Stagger draws so the eye reads continuous motion — each segment starts at
|
||||
// ~70-80% of the previous segment's duration, before it finishes.
|
||||
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,
|
||||
);
|
||||
|
||||
// Companion wordmark fades in only after the last stroke settles.
|
||||
tl.to(
|
||||
".brand-line",
|
||||
{ opacity: 1, duration: BRAND_FADE_DUR, ease: "power1.out" },
|
||||
BRAND_FADE_START,
|
||||
);
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Ring starting at 12 o'clock** — `<circle>` / `<rect>` strokes start at 3 o'clock by default; rotate the element `-90deg` so a progress ring draws from the top:
|
||||
|
||||
```html
|
||||
<circle
|
||||
cx="100"
|
||||
cy="100"
|
||||
r="60"
|
||||
id="ring"
|
||||
style="transform-origin: 100px 100px; transform: rotate(-90deg)"
|
||||
/>
|
||||
```
|
||||
|
||||
- **Linear (constant-speed) draw** — `ease: "none"` for a steady-rate "real pen" trace.
|
||||
- **Draw then fill** — for filled shapes, tween `fillOpacity: 0 → 1` AFTER the stroke completes (requires `fill-opacity: 0` initially and a real `fill` in CSS):
|
||||
|
||||
```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,
|
||||
);
|
||||
```
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| ----------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| SEGMENT_DRAW_DUR | 0.3–0.8s | fast snap vs deliberate pen trace; >~1s feels sluggish for a logo reveal |
|
||||
| FINAL_SEGMENT_DUR | 60–80% of SEGMENT_DRAW_DUR | proportional to segment length — a short connector at full duration reads slower than its siblings |
|
||||
| SEG_N_START | previous start + 70–80% of its duration | reads as continuous motion, not N isolated animations |
|
||||
| SEG_1_START | 0–0.4s | a small ~0.2s lead-in lets the viewer settle before motion |
|
||||
| BRAND_FADE_START | ≥ last stroke end (+ ~0.2s beat) | earlier and the wordmark competes with the draw |
|
||||
| BRAND_FADE_DUR | 0.3–0.8s | snap (urgent) vs glide (premium) |
|
||||
|
||||
Ease families are discrete choices: **stroke draws** use `power2.out` (a hand lifting at end of stroke) or `none` for constant speed — never `back.out` / `elastic.out` (pens don't bounce). **Fades** use `power1.out`.
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **`fill: none`** for outline-only draws — otherwise the fill appears immediately.
|
||||
- **Dasharray/dashoffset = the measured `getTotalLength()`**, set at setup; requires the SVG in the DOM (inline SVG is fine; a loaded `<image>` SVG is not).
|
||||
- **Complex paths**: if `getTotalLength()` looks wrong, overestimate slightly (`len * 1.05`) — too large is invisible at animation start; too small clips the end.
|
||||
- **Stagger multi-path draws at ~70–80%** of the previous segment's duration.
|
||||
- **A drawn line must land on something.** When the path is a connector (rail, beam, underline, callout) rather than a shape, both endpoints must sit on real elements and the draw must do a job — reveal, route, validate, or emphasize. A stroke that only decorates empty space reads as filler; attach it or cut it.
|
||||
|
||||
## See also
|
||||
|
||||
`svg-icon-enrichment` (internal parts animate after the outline draws) · `counting-dynamic-scale` (stroke draws an icon while a number counts up) · `hacker-flip-3d` (logo draws, wordmark decodes beneath).
|
||||
@@ -0,0 +1,126 @@
|
||||
---
|
||||
name: theme-crossfade-morph
|
||||
description: Whole-theme in-place morph under a fixed anchor — background, typography, corner radii, icons, chrome and logos all blend simultaneously (~0.3s) through N pre-styled skins while one anchor element never moves. Recipe = stacked full layers + opacity crossfade, anchor rendered once on top. Seek-safe by construction.
|
||||
metadata:
|
||||
tags: theme, skin, crossfade, morph, anchor, reskin, cycle, ui, stacked-layers
|
||||
---
|
||||
|
||||
# Theme Crossfade Morph
|
||||
|
||||
The whole world re-skins while one thing holds still. A composer box cycles through four IDE themes; a checkout widget flips through brand skins — background, typography, corner radii, toolbar icons, footer logos all change **at once**, in place, in ~0.3s, N times — and through every flip one anchor element (the prompt string, the widget layout, the wordmark) **never moves**. The anchor's stillness is the rhetorical claim: _everything changes, this doesn't._
|
||||
|
||||
Boundary: [card-morph-anchor.md](card-morph-anchor.md) morphs **one container** between two shots — its dimensions, radius, and surface tween continuously. This rule re-skins an **entire scene** through **N discrete states**: nothing tweens property-by-property (fonts, icons, and logos can't interpolate); the "morph" is a fast simultaneous crossfade of complete pre-styled layers. ([scale-swap-transition.md](scale-swap-transition.md) swaps an element at center; here the surroundings swap and the element holds.)
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **One skin = one complete layer.** Each theme state is a fully pre-styled, full-bleed layer (`position: absolute; inset: 0`) containing everything that changes: background, shell/chrome, toolbar icons, footer logos, typography. All `N_SKINS` layers exist in the DOM from `t=0`, stacked; skin 0 starts visible, the rest at `opacity: 0`.
|
||||
2. **The morph is a crossfade.** At each boundary, two opposing opacity tweens run at the same timeline position over `MORPH_DUR` (~0.3s): outgoing `1 → 0`, incoming `0 → 1`. Because both layers are complete, every property "blends" simultaneously for free — including the un-tweenable ones (font families, icon glyphs, logos), which read as morphing precisely because everything else is mid-blend around them.
|
||||
3. **The anchor renders once, on top.** The element that must not move lives in its own layer above all skins and is **excluded from every skin layer**. No transforms, no re-parenting, no per-skin restyle.
|
||||
4. **Windows are precomputed.** `T_k = CYCLE_START + k × (SKIN_HOLD + MORPH_DUR)`. Steady cadence by default; hold the final skin longest when it's the resolve.
|
||||
|
||||
The only animated property is `opacity` — which is why this rule is seek-safe with zero special machinery.
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<!-- inside a standard scene clip (hyperframes-core) -->
|
||||
<div class="theme-stage">
|
||||
<!-- One complete pre-styled layer per skin; skin-0 visible at t=0 -->
|
||||
<div class="skin skin-0"><div class="shell">…terminal chrome, mono type, footer badge…</div></div>
|
||||
<div class="skin skin-1">
|
||||
<div class="shell">…rounded composer, sans type, toolbar pills, logo…</div>
|
||||
</div>
|
||||
<div class="skin skin-2"><div class="shell">…dark shell, its own chrome and footer…</div></div>
|
||||
|
||||
<!-- The anchor: rendered ONCE, above every skin. It never moves. -->
|
||||
<div class="anchor" id="anchor">{anchorText}</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
```css
|
||||
.theme-stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.skin {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
/* Each skin fully self-styled: its own background, fonts, radii,
|
||||
icons, chrome, logos. Nothing inherited across skins. */
|
||||
}
|
||||
.skin-0 {
|
||||
opacity: 1; /* the opening state — matches the timeline's fromTo */
|
||||
}
|
||||
.shell {
|
||||
/* CRITICAL: shared geometry. The shell box (and any element that
|
||||
"persists" across skins — toolbar row, footer row) sits at the SAME
|
||||
coordinates in every skin, so mid-blend frames read as one UI
|
||||
changing clothes, not two UIs ghosting. */
|
||||
position: absolute;
|
||||
left: SHELL_LEFT;
|
||||
top: SHELL_TOP;
|
||||
width: SHELL_WIDTH;
|
||||
height: SHELL_HEIGHT;
|
||||
}
|
||||
.anchor {
|
||||
position: absolute;
|
||||
z-index: 10; /* above every skin */
|
||||
left: ANCHOR_LEFT;
|
||||
top: ANCHOR_TOP;
|
||||
/* No transforms, no transitions — the stillness is load-bearing. */
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
const skins = gsap.utils.toArray(".skin");
|
||||
|
||||
// Boundary k→k+1 at T_k: outgoing fades down as incoming fades up —
|
||||
// ONE simultaneous crossfade, everything blends at once.
|
||||
skins.forEach((skin, k) => {
|
||||
if (k === 0) return; // skin-0 is the opening state
|
||||
const at = CYCLE_START + k * (SKIN_HOLD + MORPH_DUR);
|
||||
tl.fromTo(skin, { opacity: 0 }, { opacity: 1, duration: MORPH_DUR, ease: "power2.inOut" }, at);
|
||||
tl.to(
|
||||
skins[k - 1],
|
||||
{ opacity: 0, duration: MORPH_DUR, ease: "power2.inOut" },
|
||||
at, // same position — the blend is simultaneous, never sequential
|
||||
);
|
||||
});
|
||||
|
||||
// The anchor gets NO tweens. Its absence from the timeline is the point.
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Anchor-typography reskin (per-layer copies)** — when the anchor's own type treatment must change with the theme (mono in the terminal skin, sans in the editor skin), each skin carries its own copy of the anchor at **pixel-identical geometry** and there is no separate top layer; the invariant shifts from "one element" to "one geometry." Verify the copies overlay exactly (screenshot two skins at 50% opacity) — a 2px baseline drift reads as the anchor flinching, which breaks the whole claim.
|
||||
- **Skin-cycle tour with logo relay** — a large brand logo outside the anchored shell crossfades **in the same windows** as the skins (logo k with skin k, same `MORPH_DUR`). The paired swap sells "same product, every brand."
|
||||
- **Washout finale** — after the last skin, a final low-key layer (faint dot-grid, blueprint wash) fades in while the last shell drops to ~0.25 opacity — the cycle resolves into a held diagram of itself. One extra window; the anchor may fade with the shell or hold full-strength.
|
||||
- **Emphasis brake** — steady cadence for `N−1` skins, then hold the final skin 2–3× `SKIN_HOLD`; the cycle demonstrates breadth, the brake lands the resolve. Precompute the hold array; don't drift the cadence without cause.
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| --------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| N_SKINS | 3–5 | two is a before/after (consider `card-morph-anchor`); past five the cycle pads |
|
||||
| SKIN_HOLD | 0.8–1.5s | long enough to register the logo/footer identity, short enough to keep the churn rhetorical |
|
||||
| MORPH_DUR | 0.25–0.4s, ~0.3s canonical | faster reads as a hard cut; slower reads as a mushy dissolve with lingering double-exposure |
|
||||
| CYCLE_START | ≥ anchor settle + a beat | after the anchor and skin-0 have fully registered |
|
||||
| SHELL geometry | — | shell / toolbar / footer coordinates identical across skins; contents inside the slots differ freely |
|
||||
| ANCHOR position | — | identical to the pixel across the scene (per-layer form: identical in every skin) |
|
||||
| washout / brake | shell ~0.2–0.3 opacity; hold 2–3× SKIN_HOLD | — |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **The anchor never moves.** No transforms, no opacity dips, no re-parenting, no restyle — the contrast between total churn and total stillness is the entire device; one flinch and the shot becomes a slideshow.
|
||||
- **Nothing tweens but `opacity`** — no `borderRadius` / `background` tweens; radii and colors change by being different in the next layer. Visibility via `opacity` only, never `display` / `visibility` toggles (they can't blend mid-fade).
|
||||
- **Pixel-align the shared geometry** — mid-blend both skins are partially visible; aligned shells read as one UI changing clothes, misaligned shells ghost into two UIs.
|
||||
- **Pre-style everything** — each skin is complete and static; no class toggling, no runtime restyle mid-tween.
|
||||
- **Outgoing and incoming tweens share one timeline position** — a staggered blend flashes the stage background between skins.
|
||||
- **Adjacent windows only** — skin k crossfades with k+1, never k+2; at no frame are three skins partially visible.
|
||||
- **Camera static — always.** A push-in on top of a theme cycle destroys the stillness that makes the anchor read.
|
||||
- **Hard cuts are the cheaper sibling** — if the states should _snap_, that's `discrete-text-sequence` territory; the ~0.3s blend is specifically the "morph" read.
|
||||
|
||||
## See also
|
||||
|
||||
`context-sensitive-cursor` (caret color switches at each `T_k`) · `discrete-text-sequence` (type the anchor first; or the hard-cut alternative) · `card-morph-anchor` (the single-container sibling) · `spring-pop-entrance` (the lockup that joins the anchor at the resolve) · `sine-wave-loop` (drifting field under the cycle — never on the anchor).
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
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", so instead of a single linear scroll you get the slot-machine "click click click" rhythm with natural settling. Distinct from a continuous marquee: this rule's semantics are discrete steps that land; for endless linear motion see [sine-wave-loop.md](sine-wave-loop.md).
|
||||
|
||||
## How It Works
|
||||
|
||||
A masked window of fixed height `ITEM_HEIGHT` (`overflow: hidden`) holds a vertical stack of items, each exactly `ITEM_HEIGHT` tall. Each spring holds a 0→1 progress; a shared `onUpdate` sums them and applies `translateY(-sum × ITEM_HEIGHT)`. Springs fire sequentially with overlap (`STEP_SPACING ≤ STEP_DUR`), so each step snaps in while the previous is still settling — that overlap is what makes them additive, and the `back.out` overshoot is what makes each step read as a "click".
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<!-- inside a standard scene clip (hyperframes-core) -->
|
||||
<div class="ticker" id="ticker">
|
||||
<div class="stack-inner" id="stack-inner">
|
||||
<div class="item">{item0}</div>
|
||||
<div class="item">{item1}</div>
|
||||
<div class="item">{itemN}</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
```css
|
||||
.ticker {
|
||||
width: TICKER_WIDTH;
|
||||
height: ITEM_HEIGHT; /* MUST match .item height exactly */
|
||||
overflow: hidden; /* the mask is the window */
|
||||
}
|
||||
.stack-inner {
|
||||
display: flex;
|
||||
flex-direction: column; /* mandatory — vertical stacking */
|
||||
}
|
||||
.item {
|
||||
height: ITEM_HEIGHT; /* MUST equal .ticker height */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* font-variant-numeric: tabular-nums; — for numeric tickers */
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
const innerEl = document.getElementById("stack-inner");
|
||||
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
|
||||
|
||||
springs.forEach((spring, i) => {
|
||||
tl.to(
|
||||
spring,
|
||||
{
|
||||
p: 1,
|
||||
duration: STEP_DUR,
|
||||
ease: `back.out(${BOUNCE_FACTOR})`,
|
||||
onUpdate: applyTransform,
|
||||
},
|
||||
STEP_START + i * STEP_SPACING,
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## Variations
|
||||
|
||||
- **Numeric ticker (price / counter rolling)** — items are the digit sequence; run the same spring-step pattern per decimal position. `font-variant-numeric: tabular-nums` required.
|
||||
- **Reverse direction (countdown)** — flip the sign (`translateY(${sumP * ITEM_HEIGHT}px)`) and arrange items in reverse order.
|
||||
- **Pause between groups** — several fast steps (small `STEP_SPACING`), a long pause, then one dramatic final step with a bigger `BOUNCE_FACTOR`. The pause is where the eye locks in.
|
||||
- **Continuous infinite ticker** — NOT this rule (this rule is discrete steps); a looping news ticker is a single linear tween with duplicated items — see [sine-wave-loop.md](sine-wave-loop.md) for continuous-motion semantics.
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| ------------- | --------------------- | ------------------------------------------------------------------------------------- |
|
||||
| ITEM_HEIGHT | ~`fontSize × 1.25` | must hold capital descenders; `.ticker` height MUST equal it exactly |
|
||||
| TICKER_WIDTH | 30–60% viewport width | wide enough for the longest item without ellipsis |
|
||||
| STEPS | 1–4 | number of transitions, not items; `STEPS ≤ itemCount − 1` |
|
||||
| STEP_DUR | 0.3–0.7s | under 0.3 the overshoot is invisible; over 0.7 the click reads as a slide |
|
||||
| STEP_SPACING | 0.3–0.5s | **≤ STEP_DUR** so springs overlap (additive); wider gaps read as a lazy linear scroll |
|
||||
| BOUNCE_FACTOR | 1.4–2.5 | 1.4 gentle click / 2.0 firm / 2.5+ casino spin-and-land for a climax step |
|
||||
|
||||
Reference: `../../examples/proof-logo-chain.html` (204px, 1 step, 0.45s).
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **Container height = item height, pixel-exact, all items equal** — mismatches show partial item edges above/below the mask and accumulate drift across steps.
|
||||
- **`overflow: hidden` on the container, not the inner stack**; `flex-direction: column` on the stack.
|
||||
- **Sum the springs in `onUpdate` — never tween the final position directly.** Each spring contributing its OWN snap is the slot-machine pacing.
|
||||
- **Overlap steps and keep `back.out` per step** — non-overlapping steps or an out-only ease collapse into a linear scroll.
|
||||
- **Never update items via `innerHTML` between steps** — the ticker moves the SAME items via translate; swapping content shows the previous item AS the new one (broken illusion).
|
||||
- **Climax dwell ≥1s after the final step** (SKILL universal constraint).
|
||||
- **`tabular-nums` for numeric tickers** — variable digit widths break alignment.
|
||||
|
||||
## See also
|
||||
|
||||
`reactive-displacement` (ticker pushed by an incoming element) · `scale-swap-transition` (ticker scales out after settling) · `press-release-spring` (button press triggers the spin).
|
||||
@@ -0,0 +1,148 @@
|
||||
---
|
||||
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) (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)` per-frame. Get the sign right or everything moves the wrong way. The single `.world` wrapper holds the camera transform; 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, solve `S × offset + T = 0`:
|
||||
|
||||
```
|
||||
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). Mixing up the two forms drifts the target off-center as scale changes. Use this single-wrapper form when you want one source of truth for camera state (`cam.scale`, `cam.x`, `cam.y`) written via `onUpdate`; use nested wrappers when scale and translate can tween independently with shared ease.
|
||||
|
||||
## Recipe
|
||||
|
||||
```html
|
||||
<div class="world" id="world">
|
||||
<div class="content">
|
||||
<div class="hero">{Brand}</div>
|
||||
<div class="tagline">{tagline}</div>
|
||||
<div class="cta" id="cta">{ctaUrl}</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
```css
|
||||
.scene {
|
||||
overflow: hidden; /* REQUIRED — any non-1.0 scale reveals edges or pushes content off-frame */
|
||||
background: {bgGradient}; /* on .scene, NOT .world — a world-borne background warps with the camera */
|
||||
}
|
||||
.world {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transform-origin: 50% 50%; /* centered scaling is what the math assumes */
|
||||
will-change: transform;
|
||||
}
|
||||
```
|
||||
|
||||
```js
|
||||
const world = document.getElementById("world");
|
||||
|
||||
// Camera state — single source of truth. The world transform is composed from
|
||||
// this object in ONE place 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(); // seed frame 0
|
||||
|
||||
// Zoom in on the CTA: single-element composite transform → T = -offset × S.
|
||||
// TARGET_OFFSET_Y is the target's measured offset from viewport center at
|
||||
// neutral camera (sign matters — positive = below center).
|
||||
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,
|
||||
);
|
||||
```
|
||||
|
||||
## 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: < 5% scale change is imperceptible; 10-15% is comfortable emphasis; > 30% is cinematic/dramatic. For a natural product feel, prefer 1.05-1.15× over 2-3s; save big > 1.3× zooms for dramatic narrative moments.
|
||||
|
||||
### Extreme range — 4–12× outward (workspace reveal)
|
||||
|
||||
The same single-cam math runs far past the table: a zoom-out workspace reveal opens punched-in at **4–12×** on one detail (a single cell, message, or button) and pulls out to the full workspace in one continuous move. The mechanics don't change — one `cam` object, `T = -offset × S`, one `applyCamera()` writer — only the authoring direction does:
|
||||
|
||||
- **Build the workspace at its final (1×) layout and OPEN scaled-in** (`cam.scale = 8`, counter-translate aiming the opening detail; state it in a `fromTo` / seed via `applyCamera()` so a seek to t=0 lands punched-in). The wide landing frame is then everything at native design size — text crisp, raster assets at source resolution.
|
||||
- **Never the inverse** — authoring the close-up at 1× and scaling the world down to 0.08–0.25 for the wide frame drops every label below legible pixel size and softens raster media; the reveal lands on mush.
|
||||
- **Measure the opening target** — at S = 8, a 1 px error in the baked offset is 8 px on screen at the opening pose. Take the offset from the target's real laid-out center (`getBoundingClientRect` after `fonts.ready`, once at setup — the measuring doctrine in [coordinate-target-zoom.md](coordinate-target-zoom.md)), never from a layout formula.
|
||||
- **The opening detail must survive ×S** — it renders at `S ×` its design size on the first frames (vector/DOM text is safe; raster needs `sourceResolution ≥ rendered × S`).
|
||||
|
||||
## Variations
|
||||
|
||||
- **Focus-lock (camera follows a moving cursor/character)** — keep the element at a fixed screen X by computing the world offset per-frame inside the driver's `onUpdate`:
|
||||
|
||||
```js
|
||||
const focusEl = document.querySelector(".moving-cursor");
|
||||
const targetScreenX = VIEWPORT_WIDTH * FOCUS_SCREEN_X_FRAC; // 0.4–0.7; 0.5 = dead center
|
||||
const focusUpdate = { p: 0 };
|
||||
tl.to(
|
||||
focusUpdate,
|
||||
{
|
||||
p: 1,
|
||||
duration: FOLLOW_DUR, // matches how long the focused element is in motion
|
||||
ease: "power2.inOut",
|
||||
onUpdate: () => {
|
||||
const rect = focusEl.getBoundingClientRect();
|
||||
cam.x = targetScreenX - (rect.left + rect.width / 2);
|
||||
applyCamera();
|
||||
},
|
||||
},
|
||||
FOLLOW_START,
|
||||
);
|
||||
```
|
||||
|
||||
- **Composite scale (multi-phase)** — two proxy tweens multiplied through one writer: `cam.scale = scaleUp.v * scaleDown.v; applyCamera()`. Combine a slow push-in (~1.15) with a brief release (~0.9) for a breath/punch shape.
|
||||
- **Camera mode transition (centered → follow)** — crossfade two camera modes via a 0→1 weight tween; intermediate frames interpolate between the modes' offsets.
|
||||
|
||||
## Values
|
||||
|
||||
| token | range | notes |
|
||||
| --------------- | ------------------------------------ | ------------------------------------------------------------------------------------------- |
|
||||
| TARGET_OFFSET_Y | measured, not a free parameter | target's offset from viewport center at neutral camera; measure via `getBoundingClientRect` |
|
||||
| TARGET_SCALE | 1.3× modest → 1.6–2.0× typical → 3×+ | raster media needs `sourceResolution ≥ rendered × TARGET_SCALE` |
|
||||
| ZOOM_START | content landed + ~0.5s scan time | let the viewer read before the camera moves |
|
||||
| ZOOM_DUR | 1.0–2.0s | under 0.8s teleports, over 2.5s drags |
|
||||
| DWELL | ≥ 1.0s after the zoom settles | the viewer must be able to read the focal point (climax dwell) |
|
||||
| VIEWPORT_WIDTH | = the root's `data-width` | real value, not abstract |
|
||||
|
||||
## Critical Constraints
|
||||
|
||||
- **One `.world` wrapper carries the whole camera** — every scene element lives inside it; a second transformed wrapper is a second camera.
|
||||
- **Single source of truth via the `cam` object + `applyCamera()`** — when scale and translate both change, write them in ONE place; never split them across tweens that touch `world.style.transform` directly (the transform string composition order becomes unpredictable).
|
||||
- **Single-wrapper counter-translate is `T = -offset × S`** — don't import the nested-wrapper `T = -offset` formula.
|
||||
- **`overflow: hidden` on `.scene`**; **`transform-origin: 50% 50%` on `.world`**; **background on `.scene`, never on `.world`**.
|
||||
|
||||
## See also
|
||||
|
||||
[coordinate-target-zoom.md](coordinate-target-zoom.md) (nested-wrapper alternative, `T = -offset`) · [multi-phase-camera.md](multi-phase-camera.md) (viewport-change inside one phase) · [sine-wave-loop.md](sine-wave-loop.md) (idle micro-drift after the viewport settles).
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: waterfall-entry
|
||||
description: Staggered ARRIVAL cascade — words/elements whip in from below (one consistent direction), each starting before the previous settles, an accelerating wave that resolves into a composed layout. Title cards, segment openers, list/feature intros. Opacity is BINARY 0→1 via tl.set — never fade an arrival.
|
||||
metadata:
|
||||
tags: entrance, cascade, stagger, kinetic-text, title-card, segment-opener, arrival, waterfall, whip
|
||||
---
|
||||
|
||||
# Waterfall Entry
|
||||
|
||||
Staggered ARRIVAL cascade: words/elements whip in from below (one consistent direction),
|
||||
each starting before the previous settles — an accelerating wave that resolves into a
|
||||
composed layout. Title cards, segment openers, list/feature intros.
|
||||
|
||||
**This is an in-scene arrival, not a seam.** Its seam sibling is the waterfall CUT
|
||||
(`cut-the-curve` doctrine skill, `seams/waterfall-cut.md`); do not mix their rules:
|
||||
|
||||
| | Entry (this rule — arrival) | Waterfall Cut (seam) |
|
||||
| ------------- | --------------------------------------------- | --------------------------------------------------------- |
|
||||
| Opacity | BINARY 0→1 via `tl.set` at entry — never fade | ignites at 0.35 mid-path — the fade IS the velocity trick |
|
||||
| Axis default | Y, from below | X, riding the current |
|
||||
| Outgoing side | none | words ramp out on mirrored power4.in |
|
||||
|
||||
## Choreography
|
||||
|
||||
- **Overlap, don't queue** — next element starts within ±2 frames of the previous
|
||||
settling; gaps SHRINK across the cascade; the last element snaps.
|
||||
- **Velocity varies by weight** — heavy/anchor elements travel further and longer;
|
||||
light words/punctuation snap in tight:
|
||||
|
||||
| Parameter | Anchor/heavy | Normal word | Light/punctuation |
|
||||
| --------- | ------------ | ----------- | ----------------- |
|
||||
| Y offset | 60–80px | 40–50px | 30–48px |
|
||||
| Duration | 0.16–0.20s | 0.13–0.16s | 0.10–0.13s |
|
||||
| Overlap | 0–2f gap | 1f overlap | 1–2f overlap |
|
||||
|
||||
- Ease `power4.out` (`expo.out` for extra snap); never `.inOut` on an entry.
|
||||
- One direction per cascade.
|
||||
- Split the FINAL word into fragments to extend the climax; fragments travel further.
|
||||
- Post-settle, the group usually slides to make room for the next beat — that's
|
||||
[nudge-curve.md](nudge-curve.md).
|
||||
|
||||
## JS
|
||||
|
||||
Each element: `tl.set` (instant reveal + offset) then `tl.to` (whip to rest).
|
||||
`nextStart = prevStart + prevDuration − (overlapFrames × F)`; +overlap = cascade,
|
||||
−overlap = deliberate gap. CSS: elements start `opacity: 0; display: inline-block`.
|
||||
|
||||
```js
|
||||
var F = 1 / 60;
|
||||
var t0 = 0.1;
|
||||
// anchor (heaviest): biggest travel, longest settle
|
||||
tl.set("#el-1", { opacity: 1, y: 80 }, t0);
|
||||
tl.to("#el-1", { y: 0, duration: 0.18, ease: "power4.out" }, t0);
|
||||
// normal word: 2 frames after the anchor finishes
|
||||
var t1 = t0 + 0.18 + 2 * F;
|
||||
tl.set("#el-2", { opacity: 1, y: 45 }, t1);
|
||||
tl.to("#el-2", { y: 0, duration: 0.15, ease: "power4.out" }, t1);
|
||||
// light word: 1 frame BEFORE the previous finishes (overlap)
|
||||
var t2 = t1 + 0.15 - F;
|
||||
tl.set("#el-3", { opacity: 1, y: 40 }, t2);
|
||||
tl.to("#el-3", { y: 0, duration: 0.14, ease: "power4.out" }, t2);
|
||||
// split final-word fragments: tightest overlap, extra travel (lighter)
|
||||
var t3 = t2 + 0.14 - F;
|
||||
tl.set("#frag-a", { opacity: 1, y: 70 }, t3);
|
||||
tl.to("#frag-a", { y: 0, duration: 0.16, ease: "power4.out" }, t3);
|
||||
var t4 = t3 + 0.14 - F;
|
||||
tl.set("#frag-b", { opacity: 1, y: 70 }, t4);
|
||||
tl.to("#frag-b", { y: 0, duration: 0.15, ease: "power4.out" }, t4);
|
||||
// punctuation: lightest, fastest
|
||||
var t5 = t4 + 0.13 - 2 * F;
|
||||
tl.set("#dot", { opacity: 1, y: 48 }, t5);
|
||||
tl.to("#dot", { y: 0, duration: 0.12, ease: "power4.out" }, t5);
|
||||
```
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
| Don't | Instead |
|
||||
| ------------------------------------------------------ | --------------------------------------------------------------------------------- |
|
||||
| Queued entries (each waits for the previous to settle) | Overlap ±1–2 frames — the cascade is a wave, not a queue |
|
||||
| Same offset/duration for every cascade element | Vary by weight: anchors travel further, punctuation snaps |
|
||||
| Gradual opacity fade on an arrival | Binary 0→1 via `tl.set` — fading fights the snap (seam cuts fade; arrivals don't) |
|
||||
Reference in New Issue
Block a user