Install
That writes one file:compositions/components/cut-the-curve.html.
Paste it into your composition
Opencompositions/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. |
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:
<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
cut-the-curve.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>
transition directional-cut velocity-match whip bridge demonstrate.