Every SKILL.md already declared the new name — name: frames, frames-cli, frames-core, and so on — but the directories were never moved, so the tree carried frames-* in content and hyperframes-* on disk. Everything that resolves a skill by path missed: the core-skill list, the plugin marketplace pin, and the docs the CLI reads at runtime. Eight tests failed on main because the files they open do not exist. The directories now match what the files say they are, and the guide the claude-design contract reads takes the name that contract already used.
8.8 KiB
name, description, metadata
| name | description | metadata | ||
|---|---|---|---|---|
| viewport-change | Virtual camera — simulate zoom / pan / focus-lock by transforming a wrapper around all scene content. Camera moves right → world translates left. |
|
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 (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, 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
<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>
.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;
}
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 afromTo/ seed viaapplyCamera()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 (
getBoundingClientRectafterfonts.ready, once at setup — the measuring doctrine in 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 needssourceResolution ≥ 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:
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
.worldwrapper carries the whole camera — every scene element lives inside it; a second transformed wrapper is a second camera. - Single source of truth via the
camobject +applyCamera()— when scale and translate both change, write them in ONE place; never split them across tweens that touchworld.style.transformdirectly (the transform string composition order becomes unpredictable). - Single-wrapper counter-translate is
T = -offset × S— don't import the nested-wrapperT = -offsetformula. overflow: hiddenon.scene;transform-origin: 50% 50%on.world; background on.scene, never on.world.
See also
coordinate-target-zoom.md (nested-wrapper alternative, T = -offset) · multi-phase-camera.md (viewport-change inside one phase) · sine-wave-loop.md (idle micro-drift after the viewport settles).