Files
hyperframes/docs/catalog/components/cut-the-curve.mdx
miga-heygenandMiguel Ángel 7b5fdb1ad2 fix(docs): emit catalog source accordion titles as plain strings (#4006)
The generated catalog pages rendered the "Source" accordion title as a
JSX expression, `<Accordion title={`name.html`}>`. Mintlify's search
indexer stringifies expression attribute nodes rather than evaluating
them, so every one of the 399 catalog pages surfaced its Source section
in search results as "[object Object]" (visible on the Scroll Feed page,
among others).

The template literal carried no escaping benefit: every title is a
single-segment `name.html` path, and a double-quoted JSX string handles
it directly. Introduce `mdxStringAttribute`, which emits a plain
`name="value"` attribute and escapes `& " < { }` as HTML character
references (MDX decodes references inside quoted attribute values, and
unlike JSON.stringify it does not rely on backslash escapes, which JSX
strings do not honour). Use it for both the Source accordion title and
the preview iframe title, and regenerate the catalog pages.

Regenerated with `npx tsx scripts/generate-catalog-pages.ts`; the only
change across the 399 pages is the one Accordion title line each.

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
2026-09-16 11:55:00 -04:00

1076 lines
43 KiB
Plaintext

---
title: "Cut the Curve"
description: "A velocity-matched directional hard cut: the outgoing subject accelerates with power4.in, swaps at peak velocity, and the incoming subject continues in the same direction with power4.out."
---
import { InstallCommand } from "/snippets/install-command.jsx";
import { VariablesExplorer } from "/snippets/variables-explorer.jsx";
## Install
<InstallCommand command="npx hyperframes add cut-the-curve" item="cut-the-curve" />
That writes one file: `compositions/components/cut-the-curve.html`.
<VariablesExplorer
previewSrc="/public/catalog/components/cut-the-curve.json"
compositionId="cut-the-curve"
compositionSrc="compositions/components/cut-the-curve.html"
variables={[{"id":"subject","type":"enum","role":"content","label":"Subject","description":"Visual payload used on both sides of the cut.","default":"cursor","options":[{"value":"cursor","label":"Cursor"},{"value":"card","label":"Card"},{"value":"scene","label":"Scene"}]},{"id":"direction","type":"enum","role":"motion","label":"Direction","description":"Shared screen direction for outgoing and incoming travel.","default":"left","options":[{"value":"left","label":"Left"},{"value":"right","label":"Right"},{"value":"up","label":"Up"},{"value":"down","label":"Down"}]},{"id":"cutFraction","type":"number","role":"timing","label":"Cut fraction","description":"Normalized point where the hard cut swaps subjects.","default":0.33,"min":0,"max":1,"step":0.01},{"id":"blurPx","type":"number","role":"style","label":"Cut blur","description":"Blur in pixels shared by both subjects at the cut seam.","default":12,"min":0,"max":40,"step":1,"unit":"px"},{"id":"exit","type":"enum","role":"timing","label":"Exit","description":"Optional departure of the incoming subject. Default none: it rests until the frame cuts.","default":"none","options":[{"value":"none","label":"None"},{"value":"fade","label":"Fade"},{"value":"up","label":"Up"}]}]}
>
```html cut-the-curve.html
<!doctype html>
<!--
cut-the-curve -- HyperFrames video primitive (transitions / bridge)
Concept: a velocity-matched directional hard cut. The outgoing subject
accelerates along one straight screen vector with power4.in. At peak
velocity, the timeline swaps identities. The incoming subject starts on
the far side of the same vector and decelerates to rest with power4.out.
Matched blur and opacity at the cut conceal the seam.
Compiled from the launch write-up for this piece.
The source identifies the production signature as a single power4 whip,
with an approximately 1:2 outgoing-to-incoming distance and duration split.
Use when: bridging attention and momentum between shots, UI states, cards,
cursor beats, or scene blocks without requiring object identity to persist.
Variables (declared in data-composition-variables below):
- subject ("cursor" | "card" | "scene", default "cursor") selects the
simple visual payload used for both sides of the cut.
- direction ("left" | "right" | "up" | "down", default "left") owns
both the motion axis and the shared screen direction.
- cutFraction (number, 0-1, default 0.33) sets the hard-cut point. The
incoming distance is derived from this fraction so seam velocity stays
matched. At 0 or 1, the corresponding zero-duration half collapses.
- blurPx (number, default 12) sets blur at the cut seam.
- exit ("none" | "fade" | "up", default "none") optionally departs the
incoming subject at the end. Enabling it reserves a short tail window
(min(0.35s, 25% of D)) after the incoming subject lands.
Envelope (transition profile, no elastic HOLD; T = D minus the exit tail,
which is 0 when exit is none):
OUT = T * cutFraction, power4.in
IN = T * (1 - cutFraction), power4.out
The default 0.33 split is approximately 1:2 in time. Incoming distance is
derived from OUT distance and the duration ratio, preserving the same
approximately 1:2 spatial split and matching velocity at the cut.
Sync point: CUT_AT = T * cutFraction, the fixed boundary between OUT and
IN. It never lands in a HOLD because this profile has no HOLD phase.
Sound cue: a short whip cut fires at CUT_AT. The primitive never plays
audio. It dispatches an `hf:sfx` CustomEvent with
{ id: "whip-cut", t: CUT_AT } for the scene mix stage to route.
EDIT ZONE: change only the subject geometry custom properties in the style
block when adapting the payload drawings. The vector ratios and the paired
power4 eases are the mechanic and must remain together.
INVARIANTS: outgoing and incoming always share one vector; the identity swap
happens only at CUT_AT; their blur and opacity values match at that seam;
exactly one paused timeline registers under the literal id "cut-the-curve".
RETIME RANGE: 0.3s to 3s. Retiming changes D only. Never timeScale the GSAP
timeline and never split the two halves into independently tuned velocities.
Mount contract: this is a template-wrapped sub-composition. Everything the
mount needs, including styles, markup, GSAP, and timeline registration,
lives inside <template>. #root fills the host box with position:absolute,
inset:0, and container-type:size. It has no data-width or data-height.
-->
<html
lang="en"
data-composition-variables='[
{ "id": "subject", "type": "enum", "role": "content", "label": "Subject", "description": "Visual payload used on both sides of the cut.", "default": "cursor", "options": [{ "value": "cursor", "label": "Cursor" }, { "value": "card", "label": "Card" }, { "value": "scene", "label": "Scene" }] },
{ "id": "direction", "type": "enum", "role": "motion", "label": "Direction", "description": "Shared screen direction for outgoing and incoming travel.", "default": "left", "options": [{ "value": "left", "label": "Left" }, { "value": "right", "label": "Right" }, { "value": "up", "label": "Up" }, { "value": "down", "label": "Down" }] },
{ "id": "cutFraction", "type": "number", "role": "timing", "label": "Cut fraction", "description": "Normalized point where the hard cut swaps subjects.", "default": 0.33, "min": 0, "max": 1, "step": 0.01 },
{ "id": "blurPx", "type": "number", "role": "style", "label": "Cut blur", "description": "Blur in pixels shared by both subjects at the cut seam.", "default": 12, "min": 0, "max": 40, "step": 1, "unit": "px" },
{ "id": "exit", "type": "enum", "role": "timing", "label": "Exit", "description": "Optional departure of the incoming subject. Default none: it rests until the frame cuts.", "default": "none", "options": [{ "value": "none", "label": "None" }, { "value": "fade", "label": "Fade" }, { "value": "up", "label": "Up" }] }
]'
>
<head>
<meta charset="UTF-8" />
<title>Cut the Curve</title>
</head>
<body>
<template>
<div id="root" data-composition-id="cut-the-curve" data-duration="1" data-fps="30">
<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
#root {
position: absolute;
inset: 0;
container-type: size;
isolation: isolate;
overflow: hidden;
color: var(--fg, #f8fafc);
font-family: var(--font-body, Inter, system-ui, sans-serif);
}
.ctc-clip,
.ctc-backdrop,
.ctc-subject {
position: absolute;
inset: 0;
}
.ctc-clip {
overflow: hidden;
}
.ctc-backdrop {
background:
radial-gradient(
circle at var(--ctc-glow-x, 24%) var(--ctc-glow-y, 28%),
color-mix(
in srgb,
var(--fg, #f8fafc) var(--ctc-glow-strength, 18%),
var(--transparent, transparent)
),
var(--transparent, transparent) var(--ctc-glow-radius, 42%)
),
var(--bg, #0b1120);
}
.ctc-subject {
display: grid;
place-items: center;
opacity: 0;
will-change: transform, filter, opacity;
}
.ctc-subject.is-outgoing {
opacity: 1;
--ctc-primary: color-mix(in srgb, var(--fg, #f8fafc) 45%, transparent);
--ctc-secondary: color-mix(in srgb, var(--fg, #f8fafc) 18%, transparent);
}
.ctc-subject.is-incoming {
--ctc-primary: color-mix(in srgb, var(--fg, #f8fafc) 72%, transparent);
--ctc-secondary: color-mix(in srgb, var(--fg, #f8fafc) 45%, transparent);
}
.ctc-art {
display: none;
}
#root[data-subject="cursor"] .ctc-cursor,
#root[data-subject="card"] .ctc-card,
#root[data-subject="scene"] .ctc-scene {
display: block;
}
.ctc-cursor {
position: relative;
width: var(--ctc-cursor-width, 15cqw);
height: var(--ctc-cursor-height, 24cqh);
}
.ctc-cursor svg {
width: var(--ctc-cursor-svg-width, 100%);
height: var(--ctc-cursor-svg-height, 100%);
overflow: visible;
filter: drop-shadow(
var(--ctc-shadow-x, 0cqw) var(--ctc-shadow-y, 1cqh) var(--ctc-shadow-blur, 1.6cqw)
color-mix(
in srgb,
var(--bg, #0b1120) var(--ctc-shadow-strength, 65%),
var(--transparent, transparent)
)
);
}
.ctc-cursor-shape {
fill: var(--ctc-primary, var(--fg, #f8fafc));
stroke: var(--fg, #f8fafc);
stroke-width: var(--ctc-cursor-stroke, 1.8);
stroke-linejoin: round;
}
.ctc-cursor-pulse {
position: absolute;
left: var(--ctc-pulse-left, -5cqw);
top: var(--ctc-pulse-top, -5cqh);
width: var(--ctc-pulse-size, 11cqw);
aspect-ratio: var(--ctc-pulse-ratio, 1);
border: var(--ctc-pulse-border, 0.25cqw) solid
color-mix(
in srgb,
var(--ctc-secondary, var(--fg, #f8fafc)) var(--ctc-pulse-strength, 58%),
var(--transparent, transparent)
);
border-radius: var(--ctc-round, 50%);
}
.ctc-card {
width: var(--ctc-card-width, 42cqw);
min-height: var(--ctc-card-height, 34cqh);
padding: var(--ctc-card-padding-y, var(--space-3, 4.5cqh))
var(--ctc-card-padding-x, var(--space-3, 4cqw));
border: var(--ctc-border-width, 0.22cqw) solid var(--border, #334155);
border-radius: var(--radius, 2cqw);
background: var(--surface, #1e293b);
box-shadow: var(--ctc-shadow-x, 0cqw) var(--ctc-card-shadow-y, 2.4cqh)
var(--ctc-card-shadow-blur, 5cqw)
color-mix(
in srgb,
var(--bg, #0b1120) var(--ctc-card-shadow-strength, 55%),
var(--transparent, transparent)
);
}
.ctc-card-chip {
width: var(--ctc-chip-width, 9cqw);
height: var(--ctc-chip-height, 2.2cqh);
border-radius: var(--ctc-pill-radius, 99cqw);
background: var(--ctc-primary, var(--fg, #f8fafc));
margin-bottom: var(--ctc-card-gap, var(--space-3, 3.2cqh));
}
.ctc-card-line,
.ctc-scene-line {
height: var(--ctc-line-height, 1.8cqh);
border-radius: var(--ctc-pill-radius, 99cqw);
background: var(--fg, #f8fafc);
}
.ctc-card-line + .ctc-card-line {
margin-top: var(--ctc-line-gap, var(--space-2, 2.2cqh));
}
.ctc-card-line:first-of-type {
width: var(--ctc-line-long, 88%);
}
.ctc-card-line:last-of-type {
width: var(--ctc-line-short, 58%);
opacity: var(--ctc-muted-opacity, 0.42);
}
.ctc-scene {
width: var(--ctc-scene-width, 68cqw);
height: var(--ctc-scene-height, 58cqh);
overflow: hidden;
border: var(--ctc-border-width, 0.22cqw) solid var(--border, #334155);
border-radius: var(--radius, 2cqw);
background: var(--surface, #1e293b);
box-shadow: var(--ctc-shadow-x, 0cqw) var(--ctc-card-shadow-y, 2.4cqh)
var(--ctc-card-shadow-blur, 5cqw)
color-mix(
in srgb,
var(--bg, #0b1120) var(--ctc-card-shadow-strength, 55%),
var(--transparent, transparent)
);
}
.ctc-scene-bar {
display: flex;
align-items: center;
gap: var(--ctc-dot-gap, var(--space-1, 1cqw));
height: var(--ctc-scene-bar-height, 8cqh);
padding-inline: var(--ctc-scene-pad, var(--space-2, 2cqw));
border-bottom: var(--ctc-border-width, 0.22cqw) solid var(--border, #334155);
}
.ctc-scene-dot {
width: var(--ctc-dot-size, 1.4cqw);
aspect-ratio: var(--ctc-pulse-ratio, 1);
border-radius: var(--ctc-round, 50%);
background: var(--ctc-primary, var(--fg, #f8fafc));
}
.ctc-scene-body {
display: grid;
grid-template-columns: var(--ctc-scene-columns, 1fr 2fr);
gap: var(--ctc-scene-gap, var(--space-3, 3cqw));
padding: var(--ctc-scene-body-pad, var(--space-3, 5cqh))
var(--ctc-scene-pad, var(--space-3, 3cqw));
}
.ctc-scene-panel {
min-height: var(--ctc-panel-height, 34cqh);
border-radius: var(--ctc-panel-radius, var(--radius, 1.2cqw));
background: color-mix(
in srgb,
var(--ctc-primary, var(--fg, #f8fafc)) var(--ctc-panel-strength, 24%),
var(--surface, #1e293b)
);
}
.ctc-scene-copy {
padding-top: var(--ctc-copy-top, var(--space-2, 2cqh));
}
.ctc-scene-line + .ctc-scene-line {
margin-top: var(--ctc-scene-line-gap, var(--space-3, 3cqh));
}
.ctc-scene-line:nth-child(1) {
width: var(--ctc-scene-line-one, 92%);
}
.ctc-scene-line:nth-child(2) {
width: var(--ctc-scene-line-two, 68%);
opacity: var(--ctc-muted-opacity, 0.42);
}
.ctc-scene-line:nth-child(3) {
width: var(--ctc-scene-line-three, 78%);
background: var(--ctc-secondary, var(--fg, #f8fafc));
}
</style>
<div
id="cut-the-curve-clip"
class="ctc-clip clip"
data-start="0"
data-duration="1"
data-track-index="0"
>
<div class="ctc-backdrop"></div>
<div class="ctc-subject is-outgoing" aria-hidden="true">
<div class="ctc-art ctc-cursor">
<span class="ctc-cursor-pulse"></span>
<svg viewBox="0 0 64 88" aria-hidden="true">
<path
class="ctc-cursor-shape"
d="M8 5 L54 43 L34 47 L45 75 L30 82 L19 53 L8 68 Z"
/>
</svg>
</div>
<div class="ctc-art ctc-card">
<div class="ctc-card-chip"></div>
<div class="ctc-card-line"></div>
<div class="ctc-card-line"></div>
</div>
<div class="ctc-art ctc-scene">
<div class="ctc-scene-bar">
<span class="ctc-scene-dot"></span><span class="ctc-scene-dot"></span
><span class="ctc-scene-dot"></span>
</div>
<div class="ctc-scene-body">
<div class="ctc-scene-panel"></div>
<div class="ctc-scene-copy">
<div class="ctc-scene-line"></div>
<div class="ctc-scene-line"></div>
<div class="ctc-scene-line"></div>
</div>
</div>
</div>
</div>
<div class="ctc-subject is-incoming" aria-hidden="true">
<div class="ctc-art ctc-cursor">
<span class="ctc-cursor-pulse"></span>
<svg viewBox="0 0 64 88" aria-hidden="true">
<path
class="ctc-cursor-shape"
d="M8 5 L54 43 L34 47 L45 75 L30 82 L19 53 L8 68 Z"
/>
</svg>
</div>
<div class="ctc-art ctc-card">
<div class="ctc-card-chip"></div>
<div class="ctc-card-line"></div>
<div class="ctc-card-line"></div>
</div>
<div class="ctc-art ctc-scene">
<div class="ctc-scene-bar">
<span class="ctc-scene-dot"></span><span class="ctc-scene-dot"></span
><span class="ctc-scene-dot"></span>
</div>
<div class="ctc-scene-body">
<div class="ctc-scene-panel"></div>
<div class="ctc-scene-copy">
<div class="ctc-scene-line"></div>
<div class="ctc-scene-line"></div>
<div class="ctc-scene-line"></div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
(function () {
"use strict";
var root = document.getElementById("root");
var outgoing = root.querySelector(".is-outgoing");
var incoming = root.querySelector(".is-incoming");
var vars =
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {};
var subject =
vars.subject === "card" || vars.subject === "scene" ? vars.subject : "cursor";
var direction =
vars.direction === "right" || vars.direction === "up" || vars.direction === "down"
? vars.direction
: "left";
var rawCut = Number(vars.cutFraction);
var cutFraction = Number.isFinite(rawCut) ? Math.max(0, Math.min(1, rawCut)) : 0.33;
var rawBlur = Number(vars.blurPx);
var blurPx = Number.isFinite(rawBlur) ? Math.max(0, rawBlur) : 12;
var exit = vars.exit === "fade" || vars.exit === "up" ? vars.exit : "none";
root.dataset.subject = subject;
// Direction is the single owner of axis, sign, unit, and base travel.
var vectors = {
left: { axis: "x", sign: -1, unit: "cqw", distance: 34 },
right: { axis: "x", sign: 1, unit: "cqw", distance: 34 },
up: { axis: "y", sign: -1, unit: "cqh", distance: 26 },
down: { axis: "y", sign: 1, unit: "cqh", distance: 26 },
};
var vector = vectors[direction];
var duration = Math.max(0.001, parseFloat(root.dataset.duration || "1"));
// Exit none keeps the whole duration for the cut profile. An
// enabled exit reserves a short tail after the incoming lands.
var EXIT = exit === "none" ? 0 : Math.min(0.35, duration * 0.25);
var TRAVEL = Math.max(0.001, duration - EXIT);
var CUT_AT = TRAVEL * cutFraction;
var ENTRY_DURATION = TRAVEL - CUT_AT;
var entryDistance =
CUT_AT > 0 ? vector.distance * (ENTRY_DURATION / CUT_AT) : vector.distance * 2;
var seamBlur = "blur(" + blurPx + "px)";
var clearBlur = "blur(0px)";
var seamOpacity = 0.72;
var outgoingEnd = { opacity: seamOpacity, filter: seamBlur };
outgoingEnd[vector.axis] = vector.sign * vector.distance + vector.unit;
var incomingStart = { x: 0, y: 0, opacity: 0, filter: seamBlur };
incomingStart[vector.axis] = -vector.sign * entryDistance + vector.unit;
var incomingEnd = { opacity: 1, filter: clearBlur };
incomingEnd[vector.axis] = 0;
gsap.set(outgoing, { x: 0, y: 0, opacity: 1, filter: clearBlur });
gsap.set(incoming, incomingStart);
var tl = gsap.timeline({ paused: true });
if (CUT_AT > 0) {
tl.to(
outgoing,
Object.assign({}, outgoingEnd, { duration: CUT_AT, ease: "power4.in" }),
0,
);
}
// CUT: one visibility owner, matched blur and opacity on both sides.
tl.set(outgoing, { opacity: 0 }, CUT_AT);
tl.set(incoming, { opacity: seamOpacity }, CUT_AT);
tl.call(
function () {
root.dispatchEvent(
new CustomEvent("hf:sfx", {
detail: { id: "whip-cut", t: CUT_AT },
bubbles: true,
}),
);
},
[],
CUT_AT,
);
if (ENTRY_DURATION > 0) {
tl.to(
incoming,
Object.assign({}, incomingEnd, {
duration: ENTRY_DURATION,
ease: "power4.out",
}),
CUT_AT,
);
} else {
tl.set(incoming, incomingEnd, CUT_AT);
}
// Optional departure; exit none rests until the frame cuts.
if (exit === "fade") {
tl.to(incoming, { opacity: 0, duration: EXIT, ease: "power2.in" }, TRAVEL);
} else if (exit === "up") {
tl.to(
incoming,
{ opacity: 0, y: "-6cqh", duration: EXIT, ease: "power2.in" },
TRAVEL,
);
}
tl.seek(0);
window.__timelines = window.__timelines || {};
window.__timelines["cut-the-curve"] = tl;
})();
</script>
</div>
</template>
</body>
</html>
```
</VariablesExplorer>
## Paste it into your composition
Open `compositions/components/cut-the-curve.html` and copy what is inside into your own composition.
A component has no size or duration of its own. It takes both from the composition
you paste it into.
## Variables
Every one of these has a default, so the piece works untouched. Set the ones you
want to change on the element:
| Variable | Default | Accepts | What it does |
| --- | --- | --- | --- |
| `subject` | `cursor` | `cursor`, `card`, `scene` | Visual payload used on both sides of the cut. |
| `direction` | `left` | `left`, `right`, `up`, `down` | Shared screen direction for outgoing and incoming travel. |
| `cutFraction` | `0.33` | 0 to 1, step 0.01 | Normalized point where the hard cut swaps subjects. |
| `blurPx` | `12` | 0px to 40px, step 1px | Blur in pixels shared by both subjects at the cut seam. |
| `exit` | `none` | `none`, `fade`, `up` | Optional departure of the incoming subject. Default none: it rests until the frame cuts. |
Set them with `data-variable-values` on the element that mounts it. These are the
defaults, so this behaves exactly like the preview above until you change one:
```html wrap
<div
data-composition-id="cut-the-curve"
data-composition-src="compositions/components/cut-the-curve.html"
data-variable-values='{"subject":"cursor","direction":"left","cutFraction":0.33,"blurPx":12,"exit":"none"}'
></div>
```
## Source
<Accordion title="cut-the-curve.html">
```html
<!doctype html>
<!--
cut-the-curve -- HyperFrames video primitive (transitions / bridge)
Concept: a velocity-matched directional hard cut. The outgoing subject
accelerates along one straight screen vector with power4.in. At peak
velocity, the timeline swaps identities. The incoming subject starts on
the far side of the same vector and decelerates to rest with power4.out.
Matched blur and opacity at the cut conceal the seam.
Compiled from the launch write-up for this piece.
The source identifies the production signature as a single power4 whip,
with an approximately 1:2 outgoing-to-incoming distance and duration split.
Use when: bridging attention and momentum between shots, UI states, cards,
cursor beats, or scene blocks without requiring object identity to persist.
Variables (declared in data-composition-variables below):
- subject ("cursor" | "card" | "scene", default "cursor") selects the
simple visual payload used for both sides of the cut.
- direction ("left" | "right" | "up" | "down", default "left") owns
both the motion axis and the shared screen direction.
- cutFraction (number, 0-1, default 0.33) sets the hard-cut point. The
incoming distance is derived from this fraction so seam velocity stays
matched. At 0 or 1, the corresponding zero-duration half collapses.
- blurPx (number, default 12) sets blur at the cut seam.
- exit ("none" | "fade" | "up", default "none") optionally departs the
incoming subject at the end. Enabling it reserves a short tail window
(min(0.35s, 25% of D)) after the incoming subject lands.
Envelope (transition profile, no elastic HOLD; T = D minus the exit tail,
which is 0 when exit is none):
OUT = T * cutFraction, power4.in
IN = T * (1 - cutFraction), power4.out
The default 0.33 split is approximately 1:2 in time. Incoming distance is
derived from OUT distance and the duration ratio, preserving the same
approximately 1:2 spatial split and matching velocity at the cut.
Sync point: CUT_AT = T * cutFraction, the fixed boundary between OUT and
IN. It never lands in a HOLD because this profile has no HOLD phase.
Sound cue: a short whip cut fires at CUT_AT. The primitive never plays
audio. It dispatches an `hf:sfx` CustomEvent with
{ id: "whip-cut", t: CUT_AT } for the scene mix stage to route.
EDIT ZONE: change only the subject geometry custom properties in the style
block when adapting the payload drawings. The vector ratios and the paired
power4 eases are the mechanic and must remain together.
INVARIANTS: outgoing and incoming always share one vector; the identity swap
happens only at CUT_AT; their blur and opacity values match at that seam;
exactly one paused timeline registers under the literal id "cut-the-curve".
RETIME RANGE: 0.3s to 3s. Retiming changes D only. Never timeScale the GSAP
timeline and never split the two halves into independently tuned velocities.
Mount contract: this is a template-wrapped sub-composition. Everything the
mount needs, including styles, markup, GSAP, and timeline registration,
lives inside <template>. #root fills the host box with position:absolute,
inset:0, and container-type:size. It has no data-width or data-height.
-->
<html
lang="en"
data-composition-variables='[
{ "id": "subject", "type": "enum", "role": "content", "label": "Subject", "description": "Visual payload used on both sides of the cut.", "default": "cursor", "options": [{ "value": "cursor", "label": "Cursor" }, { "value": "card", "label": "Card" }, { "value": "scene", "label": "Scene" }] },
{ "id": "direction", "type": "enum", "role": "motion", "label": "Direction", "description": "Shared screen direction for outgoing and incoming travel.", "default": "left", "options": [{ "value": "left", "label": "Left" }, { "value": "right", "label": "Right" }, { "value": "up", "label": "Up" }, { "value": "down", "label": "Down" }] },
{ "id": "cutFraction", "type": "number", "role": "timing", "label": "Cut fraction", "description": "Normalized point where the hard cut swaps subjects.", "default": 0.33, "min": 0, "max": 1, "step": 0.01 },
{ "id": "blurPx", "type": "number", "role": "style", "label": "Cut blur", "description": "Blur in pixels shared by both subjects at the cut seam.", "default": 12, "min": 0, "max": 40, "step": 1, "unit": "px" },
{ "id": "exit", "type": "enum", "role": "timing", "label": "Exit", "description": "Optional departure of the incoming subject. Default none: it rests until the frame cuts.", "default": "none", "options": [{ "value": "none", "label": "None" }, { "value": "fade", "label": "Fade" }, { "value": "up", "label": "Up" }] }
]'
>
<head>
<meta charset="UTF-8" />
<title>Cut the Curve</title>
</head>
<body>
<template>
<div id="root" data-composition-id="cut-the-curve" data-duration="1" data-fps="30">
<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
#root {
position: absolute;
inset: 0;
container-type: size;
isolation: isolate;
overflow: hidden;
color: var(--fg, #f8fafc);
font-family: var(--font-body, Inter, system-ui, sans-serif);
}
.ctc-clip,
.ctc-backdrop,
.ctc-subject {
position: absolute;
inset: 0;
}
.ctc-clip {
overflow: hidden;
}
.ctc-backdrop {
background:
radial-gradient(
circle at var(--ctc-glow-x, 24%) var(--ctc-glow-y, 28%),
color-mix(
in srgb,
var(--fg, #f8fafc) var(--ctc-glow-strength, 18%),
var(--transparent, transparent)
),
var(--transparent, transparent) var(--ctc-glow-radius, 42%)
),
var(--bg, #0b1120);
}
.ctc-subject {
display: grid;
place-items: center;
opacity: 0;
will-change: transform, filter, opacity;
}
.ctc-subject.is-outgoing {
opacity: 1;
--ctc-primary: color-mix(in srgb, var(--fg, #f8fafc) 45%, transparent);
--ctc-secondary: color-mix(in srgb, var(--fg, #f8fafc) 18%, transparent);
}
.ctc-subject.is-incoming {
--ctc-primary: color-mix(in srgb, var(--fg, #f8fafc) 72%, transparent);
--ctc-secondary: color-mix(in srgb, var(--fg, #f8fafc) 45%, transparent);
}
.ctc-art {
display: none;
}
#root[data-subject="cursor"] .ctc-cursor,
#root[data-subject="card"] .ctc-card,
#root[data-subject="scene"] .ctc-scene {
display: block;
}
.ctc-cursor {
position: relative;
width: var(--ctc-cursor-width, 15cqw);
height: var(--ctc-cursor-height, 24cqh);
}
.ctc-cursor svg {
width: var(--ctc-cursor-svg-width, 100%);
height: var(--ctc-cursor-svg-height, 100%);
overflow: visible;
filter: drop-shadow(
var(--ctc-shadow-x, 0cqw) var(--ctc-shadow-y, 1cqh) var(--ctc-shadow-blur, 1.6cqw)
color-mix(
in srgb,
var(--bg, #0b1120) var(--ctc-shadow-strength, 65%),
var(--transparent, transparent)
)
);
}
.ctc-cursor-shape {
fill: var(--ctc-primary, var(--fg, #f8fafc));
stroke: var(--fg, #f8fafc);
stroke-width: var(--ctc-cursor-stroke, 1.8);
stroke-linejoin: round;
}
.ctc-cursor-pulse {
position: absolute;
left: var(--ctc-pulse-left, -5cqw);
top: var(--ctc-pulse-top, -5cqh);
width: var(--ctc-pulse-size, 11cqw);
aspect-ratio: var(--ctc-pulse-ratio, 1);
border: var(--ctc-pulse-border, 0.25cqw) solid
color-mix(
in srgb,
var(--ctc-secondary, var(--fg, #f8fafc)) var(--ctc-pulse-strength, 58%),
var(--transparent, transparent)
);
border-radius: var(--ctc-round, 50%);
}
.ctc-card {
width: var(--ctc-card-width, 42cqw);
min-height: var(--ctc-card-height, 34cqh);
padding: var(--ctc-card-padding-y, var(--space-3, 4.5cqh))
var(--ctc-card-padding-x, var(--space-3, 4cqw));
border: var(--ctc-border-width, 0.22cqw) solid var(--border, #334155);
border-radius: var(--radius, 2cqw);
background: var(--surface, #1e293b);
box-shadow: var(--ctc-shadow-x, 0cqw) var(--ctc-card-shadow-y, 2.4cqh)
var(--ctc-card-shadow-blur, 5cqw)
color-mix(
in srgb,
var(--bg, #0b1120) var(--ctc-card-shadow-strength, 55%),
var(--transparent, transparent)
);
}
.ctc-card-chip {
width: var(--ctc-chip-width, 9cqw);
height: var(--ctc-chip-height, 2.2cqh);
border-radius: var(--ctc-pill-radius, 99cqw);
background: var(--ctc-primary, var(--fg, #f8fafc));
margin-bottom: var(--ctc-card-gap, var(--space-3, 3.2cqh));
}
.ctc-card-line,
.ctc-scene-line {
height: var(--ctc-line-height, 1.8cqh);
border-radius: var(--ctc-pill-radius, 99cqw);
background: var(--fg, #f8fafc);
}
.ctc-card-line + .ctc-card-line {
margin-top: var(--ctc-line-gap, var(--space-2, 2.2cqh));
}
.ctc-card-line:first-of-type {
width: var(--ctc-line-long, 88%);
}
.ctc-card-line:last-of-type {
width: var(--ctc-line-short, 58%);
opacity: var(--ctc-muted-opacity, 0.42);
}
.ctc-scene {
width: var(--ctc-scene-width, 68cqw);
height: var(--ctc-scene-height, 58cqh);
overflow: hidden;
border: var(--ctc-border-width, 0.22cqw) solid var(--border, #334155);
border-radius: var(--radius, 2cqw);
background: var(--surface, #1e293b);
box-shadow: var(--ctc-shadow-x, 0cqw) var(--ctc-card-shadow-y, 2.4cqh)
var(--ctc-card-shadow-blur, 5cqw)
color-mix(
in srgb,
var(--bg, #0b1120) var(--ctc-card-shadow-strength, 55%),
var(--transparent, transparent)
);
}
.ctc-scene-bar {
display: flex;
align-items: center;
gap: var(--ctc-dot-gap, var(--space-1, 1cqw));
height: var(--ctc-scene-bar-height, 8cqh);
padding-inline: var(--ctc-scene-pad, var(--space-2, 2cqw));
border-bottom: var(--ctc-border-width, 0.22cqw) solid var(--border, #334155);
}
.ctc-scene-dot {
width: var(--ctc-dot-size, 1.4cqw);
aspect-ratio: var(--ctc-pulse-ratio, 1);
border-radius: var(--ctc-round, 50%);
background: var(--ctc-primary, var(--fg, #f8fafc));
}
.ctc-scene-body {
display: grid;
grid-template-columns: var(--ctc-scene-columns, 1fr 2fr);
gap: var(--ctc-scene-gap, var(--space-3, 3cqw));
padding: var(--ctc-scene-body-pad, var(--space-3, 5cqh))
var(--ctc-scene-pad, var(--space-3, 3cqw));
}
.ctc-scene-panel {
min-height: var(--ctc-panel-height, 34cqh);
border-radius: var(--ctc-panel-radius, var(--radius, 1.2cqw));
background: color-mix(
in srgb,
var(--ctc-primary, var(--fg, #f8fafc)) var(--ctc-panel-strength, 24%),
var(--surface, #1e293b)
);
}
.ctc-scene-copy {
padding-top: var(--ctc-copy-top, var(--space-2, 2cqh));
}
.ctc-scene-line + .ctc-scene-line {
margin-top: var(--ctc-scene-line-gap, var(--space-3, 3cqh));
}
.ctc-scene-line:nth-child(1) {
width: var(--ctc-scene-line-one, 92%);
}
.ctc-scene-line:nth-child(2) {
width: var(--ctc-scene-line-two, 68%);
opacity: var(--ctc-muted-opacity, 0.42);
}
.ctc-scene-line:nth-child(3) {
width: var(--ctc-scene-line-three, 78%);
background: var(--ctc-secondary, var(--fg, #f8fafc));
}
</style>
<div
id="cut-the-curve-clip"
class="ctc-clip clip"
data-start="0"
data-duration="1"
data-track-index="0"
>
<div class="ctc-backdrop"></div>
<div class="ctc-subject is-outgoing" aria-hidden="true">
<div class="ctc-art ctc-cursor">
<span class="ctc-cursor-pulse"></span>
<svg viewBox="0 0 64 88" aria-hidden="true">
<path
class="ctc-cursor-shape"
d="M8 5 L54 43 L34 47 L45 75 L30 82 L19 53 L8 68 Z"
/>
</svg>
</div>
<div class="ctc-art ctc-card">
<div class="ctc-card-chip"></div>
<div class="ctc-card-line"></div>
<div class="ctc-card-line"></div>
</div>
<div class="ctc-art ctc-scene">
<div class="ctc-scene-bar">
<span class="ctc-scene-dot"></span><span class="ctc-scene-dot"></span
><span class="ctc-scene-dot"></span>
</div>
<div class="ctc-scene-body">
<div class="ctc-scene-panel"></div>
<div class="ctc-scene-copy">
<div class="ctc-scene-line"></div>
<div class="ctc-scene-line"></div>
<div class="ctc-scene-line"></div>
</div>
</div>
</div>
</div>
<div class="ctc-subject is-incoming" aria-hidden="true">
<div class="ctc-art ctc-cursor">
<span class="ctc-cursor-pulse"></span>
<svg viewBox="0 0 64 88" aria-hidden="true">
<path
class="ctc-cursor-shape"
d="M8 5 L54 43 L34 47 L45 75 L30 82 L19 53 L8 68 Z"
/>
</svg>
</div>
<div class="ctc-art ctc-card">
<div class="ctc-card-chip"></div>
<div class="ctc-card-line"></div>
<div class="ctc-card-line"></div>
</div>
<div class="ctc-art ctc-scene">
<div class="ctc-scene-bar">
<span class="ctc-scene-dot"></span><span class="ctc-scene-dot"></span
><span class="ctc-scene-dot"></span>
</div>
<div class="ctc-scene-body">
<div class="ctc-scene-panel"></div>
<div class="ctc-scene-copy">
<div class="ctc-scene-line"></div>
<div class="ctc-scene-line"></div>
<div class="ctc-scene-line"></div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
(function () {
"use strict";
var root = document.getElementById("root");
var outgoing = root.querySelector(".is-outgoing");
var incoming = root.querySelector(".is-incoming");
var vars =
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {};
var subject =
vars.subject === "card" || vars.subject === "scene" ? vars.subject : "cursor";
var direction =
vars.direction === "right" || vars.direction === "up" || vars.direction === "down"
? vars.direction
: "left";
var rawCut = Number(vars.cutFraction);
var cutFraction = Number.isFinite(rawCut) ? Math.max(0, Math.min(1, rawCut)) : 0.33;
var rawBlur = Number(vars.blurPx);
var blurPx = Number.isFinite(rawBlur) ? Math.max(0, rawBlur) : 12;
var exit = vars.exit === "fade" || vars.exit === "up" ? vars.exit : "none";
root.dataset.subject = subject;
// Direction is the single owner of axis, sign, unit, and base travel.
var vectors = {
left: { axis: "x", sign: -1, unit: "cqw", distance: 34 },
right: { axis: "x", sign: 1, unit: "cqw", distance: 34 },
up: { axis: "y", sign: -1, unit: "cqh", distance: 26 },
down: { axis: "y", sign: 1, unit: "cqh", distance: 26 },
};
var vector = vectors[direction];
var duration = Math.max(0.001, parseFloat(root.dataset.duration || "1"));
// Exit none keeps the whole duration for the cut profile. An
// enabled exit reserves a short tail after the incoming lands.
var EXIT = exit === "none" ? 0 : Math.min(0.35, duration * 0.25);
var TRAVEL = Math.max(0.001, duration - EXIT);
var CUT_AT = TRAVEL * cutFraction;
var ENTRY_DURATION = TRAVEL - CUT_AT;
var entryDistance =
CUT_AT > 0 ? vector.distance * (ENTRY_DURATION / CUT_AT) : vector.distance * 2;
var seamBlur = "blur(" + blurPx + "px)";
var clearBlur = "blur(0px)";
var seamOpacity = 0.72;
var outgoingEnd = { opacity: seamOpacity, filter: seamBlur };
outgoingEnd[vector.axis] = vector.sign * vector.distance + vector.unit;
var incomingStart = { x: 0, y: 0, opacity: 0, filter: seamBlur };
incomingStart[vector.axis] = -vector.sign * entryDistance + vector.unit;
var incomingEnd = { opacity: 1, filter: clearBlur };
incomingEnd[vector.axis] = 0;
gsap.set(outgoing, { x: 0, y: 0, opacity: 1, filter: clearBlur });
gsap.set(incoming, incomingStart);
var tl = gsap.timeline({ paused: true });
if (CUT_AT > 0) {
tl.to(
outgoing,
Object.assign({}, outgoingEnd, { duration: CUT_AT, ease: "power4.in" }),
0,
);
}
// CUT: one visibility owner, matched blur and opacity on both sides.
tl.set(outgoing, { opacity: 0 }, CUT_AT);
tl.set(incoming, { opacity: seamOpacity }, CUT_AT);
tl.call(
function () {
root.dispatchEvent(
new CustomEvent("hf:sfx", {
detail: { id: "whip-cut", t: CUT_AT },
bubbles: true,
}),
);
},
[],
CUT_AT,
);
if (ENTRY_DURATION > 0) {
tl.to(
incoming,
Object.assign({}, incomingEnd, {
duration: ENTRY_DURATION,
ease: "power4.out",
}),
CUT_AT,
);
} else {
tl.set(incoming, incomingEnd, CUT_AT);
}
// Optional departure; exit none rests until the frame cuts.
if (exit === "fade") {
tl.to(incoming, { opacity: 0, duration: EXIT, ease: "power2.in" }, TRAVEL);
} else if (exit === "up") {
tl.to(
incoming,
{ opacity: 0, y: "-6cqh", duration: EXIT, ease: "power2.in" },
TRAVEL,
);
}
tl.seek(0);
window.__timelines = window.__timelines || {};
window.__timelines["cut-the-curve"] = tl;
})();
</script>
</div>
</template>
</body>
</html>
```
</Accordion>
{/* hf:generated-footer */}
Tagged `transition` `directional-cut` `velocity-match` `whip` `bridge` `demonstrate`.
## Related topics
- [Browse the complete Catalog](/catalog)
- [Add assets and Catalog items in Studio](/studio/assets-and-blocks)
- [Build a richer composition](/go-further)