Files
hyperframes/docs/catalog/components/match-cut.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

692 lines
24 KiB
Plaintext

---
title: "Match Cut"
description: "A circle accelerates into a hard single-frame scene swap, matches the incoming circle exactly, and continues into a smaller resting pose."
---
import { InstallCommand } from "/snippets/install-command.jsx";
import { VariablesExplorer } from "/snippets/variables-explorer.jsx";
## Install
<InstallCommand command="npx hyperframes add match-cut" item="match-cut" />
That writes one file: `compositions/components/match-cut.html`.
<VariablesExplorer
previewSrc="/public/catalog/components/match-cut.json"
compositionId="match-cut"
compositionSrc="compositions/components/match-cut.html"
variables={[{"id":"accent","type":"enum","role":"style","label":"Accent","description":"Color shared by the matched circles.","default":"green","options":[{"value":"green","label":"Green"},{"value":"blue","label":"Blue"},{"value":"violet","label":"Violet"}]},{"id":"label_a","type":"string","role":"content","label":"Scene A label","description":"Text inside the outgoing button.","default":"Start"},{"id":"label_b","type":"string","role":"content","label":"Scene B label","description":"Result text revealed after the cut.","default":"Done"}]}
>
```html match-cut.html
<!doctype html>
<!--
match-cut: HyperFrames video primitive (transitions / match-cut / connect)
Concept: scene A drives a circle into a known center-frame geometry, then a
hard single-frame swap reveals scene B with a different circle in the exact
same geometry. Scene B begins shrinking immediately, so the cut lands while
the matched shape is moving rather than on a settled pose.
Variables:
- accent (green | blue | violet, default green): matched-circle color.
- label_a (string, default "Start"): scene A button label.
- label_b (string, default "Done"): scene B result label.
Envelope (fixed IN/OUT, elastic HOLD only, never timeScale):
IN_BASE = 2.35s scene A enters and accelerates into the hard cut, then
scene B decelerates into place while its content reveals
HOLD = elastic = max(0, D - (IN_BASE + OUT_BASE)); the matched circle
drifts subtly so a longer mount stays alive
OUT_BASE = 0.50s the completed scene fades cleanly
If D is shorter than IN_BASE + OUT_BASE, IN and OUT compress together and
HOLD becomes zero.
Sync point: hard-cut occurs at CUT_AT = 1.40s in the default four-second
duration, exactly frame 42 at 30 fps. Scene A opacity becomes 0 and scene B
opacity becomes 1 at the same timeline instant. There is no crossfade.
Mount contract: this is a template-wrapped sub-composition. The root has no
data-width or data-height, fills its host box, uses container query units,
and registers one paused timeline under the literal match-cut key.
-->
<html
lang="en"
data-composition-id="match-cut"
data-composition-duration="4"
data-composition-variables='[
{ "id": "accent", "type": "enum", "role": "style", "label": "Accent", "description": "Color shared by the matched circles.", "default": "green", "options": [{ "value": "green", "label": "Green" }, { "value": "blue", "label": "Blue" }, { "value": "violet", "label": "Violet" }] },
{ "id": "label_a", "type": "string", "role": "content", "label": "Scene A label", "description": "Text inside the outgoing button.", "default": "Start" },
{ "id": "label_b", "type": "string", "role": "content", "label": "Scene B label", "description": "Result text revealed after the cut.", "default": "Done" }
]'
>
<head>
<meta charset="UTF-8" />
<title>Match Cut</title>
</head>
<body>
<template>
<div id="root" data-composition-id="match-cut" data-duration="4" data-fps="30">
<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
#root {
position: absolute;
inset: 0;
overflow: hidden;
container-type: size;
isolation: isolate;
color: #f8fafc;
font-family: Inter, system-ui, sans-serif;
pointer-events: none;
}
.mc-clip,
.mc-shell,
.mc-scene {
position: absolute;
inset: 0;
overflow: hidden;
}
.mc-scene {
display: grid;
place-items: center;
}
.mc-scene-a {
background: radial-gradient(circle at 50% 50%, #182032 0%, #0b0f18 68%);
}
.mc-scene-b {
background: radial-gradient(circle at 50% 44%, #202b42 0%, #0d121d 72%);
opacity: 0;
}
/* Both actors share this one geometry owner. At the cut, each is
centered at scale 1, so their painted circles match exactly. */
.mc-circle {
display: grid;
place-items: center;
width: 32cqmin;
aspect-ratio: 1;
border-radius: 50%;
background: var(--mc-accent, #4ade80);
box-shadow: 0 3.2cqh 8cqh color-mix(in srgb, var(--mc-accent, #4ade80) 30%, transparent);
transform-origin: 50% 50%;
will-change: transform;
}
.mc-label-a {
display: block;
max-width: 78%;
overflow: hidden;
color: #081018;
font-size: 5.2cqmin;
font-weight: 780;
letter-spacing: -0.035em;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.mc-circle-b {
color: #081018;
}
.mc-check {
width: 34%;
height: 22%;
border-left: 2.4cqmin solid currentColor;
border-bottom: 2.4cqmin solid currentColor;
transform: translateY(-8%) rotate(-45deg);
}
.mc-copy-b {
position: absolute;
top: 69%;
left: 12cqw;
right: 12cqw;
display: flex;
flex-direction: column;
align-items: center;
gap: 1.6cqh;
text-align: center;
}
.mc-kicker,
.mc-label-b {
opacity: 0;
}
.mc-kicker {
color: color-mix(in srgb, var(--mc-accent, #4ade80) 76%, #f8fafc);
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 2.1cqmin;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.mc-label-b {
display: block;
max-width: 74cqw;
overflow: hidden;
color: #f8fafc;
font-size: 6.8cqmin;
font-weight: 760;
letter-spacing: -0.04em;
line-height: 1.05;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
<div
id="match-cut-clip"
class="mc-clip clip"
data-start="0"
data-duration="4"
data-track-index="0"
>
<div class="mc-shell">
<section class="mc-scene mc-scene-a" aria-label="Outgoing scene">
<div class="mc-circle mc-circle-a">
<span class="mc-label-a"></span>
</div>
</section>
<section class="mc-scene mc-scene-b" aria-label="Incoming scene">
<div class="mc-circle mc-circle-b" aria-hidden="true">
<span class="mc-check"></span>
</div>
<div class="mc-copy-b">
<span class="mc-kicker">Match complete</span>
<span class="mc-label-b"></span>
</div>
</section>
</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 shell = root.querySelector(".mc-shell");
var sceneA = root.querySelector(".mc-scene-a");
var sceneB = root.querySelector(".mc-scene-b");
var circleA = root.querySelector(".mc-circle-a");
var circleB = root.querySelector(".mc-circle-b");
var labelA = root.querySelector(".mc-label-a");
var labelB = root.querySelector(".mc-label-b");
var revealB = root.querySelectorAll(".mc-kicker, .mc-label-b");
var vars =
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {};
var accentName =
vars.accent === "blue" || vars.accent === "violet" ? vars.accent : "green";
var accents = { green: "#4ade80", blue: "#60a5fa", violet: "#a78bfa" };
var labelAText = vars.label_a == null ? "Start" : String(vars.label_a);
var labelBText = vars.label_b == null ? "Done" : String(vars.label_b);
root.style.setProperty("--mc-accent", accents[accentName]);
labelA.textContent = labelAText;
labelB.textContent = labelBText;
var IN_BASE = 2.35;
var OUT_BASE = 0.5;
var CUT_AT_BASE = 1.4;
var A_APPROACH_BASE = 0.45;
var B_SETTLE_BASE = 0.65;
var COPY_DELAY_BASE = 0.1;
var COPY_IN_BASE = 0.55;
var duration = Math.max(0.001, parseFloat(root.dataset.duration || "4"));
var envelopeBase = IN_BASE + OUT_BASE;
var phaseScale = duration < envelopeBase ? duration / envelopeBase : 1;
var IN = IN_BASE * phaseScale;
var OUT = OUT_BASE * phaseScale;
var CUT_AT = CUT_AT_BASE * phaseScale;
var A_APPROACH = A_APPROACH_BASE * phaseScale;
var B_SETTLE = B_SETTLE_BASE * phaseScale;
var COPY_DELAY = COPY_DELAY_BASE * phaseScale;
var COPY_IN = COPY_IN_BASE * phaseScale;
var HOLD = Math.max(0, duration - IN - OUT);
var HOLD_START = IN;
var OUT_START = IN + HOLD;
var tl = gsap.timeline({ paused: true });
tl.set(shell, { opacity: 0 }, 0);
tl.set(sceneA, { opacity: 1 }, 0);
tl.set(sceneB, { opacity: 0 }, 0);
tl.set(circleA, { scale: 0.54 }, 0);
tl.set(circleB, { scale: 1, y: 0 }, 0);
tl.set(revealB, { opacity: 0, y: "2.4cqh" }, 0);
tl.to(shell, { opacity: 1, duration: Math.min(0.3, IN), ease: "power2.out" }, 0);
tl.to(circleA, { scale: 0.64, duration: A_APPROACH, ease: "power3.out" }, 0);
tl.to(
circleA,
{
scale: 1,
duration: Math.max(0, CUT_AT - A_APPROACH),
ease: "power4.in",
},
A_APPROACH,
);
// HARD CUT: both opacity states change at the exact same instant.
tl.set(sceneA, { opacity: 0 }, CUT_AT);
tl.set(sceneB, { opacity: 1 }, CUT_AT);
// Scene B starts at the matching geometry and leaves the cut at
// peak speed, then decelerates into its smaller resting pose.
tl.to(circleB, { scale: 0.74, duration: B_SETTLE, ease: "power4.out" }, CUT_AT);
tl.to(
revealB,
{
opacity: 1,
y: 0,
duration: COPY_IN,
stagger: 0.08 * phaseScale,
ease: "power3.out",
},
CUT_AT + COPY_DELAY,
);
if (HOLD > 0) {
tl.to(
circleB,
{
y: "-0.8cqh",
duration: HOLD / 2,
ease: "sine.inOut",
yoyo: true,
repeat: 1,
},
HOLD_START,
);
}
tl.to(shell, { opacity: 0, duration: OUT, ease: "power2.in" }, OUT_START);
tl.seek(0);
window.__timelines = window.__timelines || {};
window.__timelines["match-cut"] = tl;
})();
</script>
</div>
</template>
</body>
</html>
```
</VariablesExplorer>
## Paste it into your composition
Open `compositions/components/match-cut.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 |
| --- | --- | --- | --- |
| `accent` | `green` | `green`, `blue`, `violet` | Color shared by the matched circles. |
| `label_a` | `Start` | string | Text inside the outgoing button. |
| `label_b` | `Done` | string | Result text revealed after the cut. |
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="match-cut"
data-composition-src="compositions/components/match-cut.html"
data-variable-values='{"accent":"green","label_a":"Start","label_b":"Done"}'
></div>
```
## Source
<Accordion title="match-cut.html">
```html
<!doctype html>
<!--
match-cut: HyperFrames video primitive (transitions / match-cut / connect)
Concept: scene A drives a circle into a known center-frame geometry, then a
hard single-frame swap reveals scene B with a different circle in the exact
same geometry. Scene B begins shrinking immediately, so the cut lands while
the matched shape is moving rather than on a settled pose.
Variables:
- accent (green | blue | violet, default green): matched-circle color.
- label_a (string, default "Start"): scene A button label.
- label_b (string, default "Done"): scene B result label.
Envelope (fixed IN/OUT, elastic HOLD only, never timeScale):
IN_BASE = 2.35s scene A enters and accelerates into the hard cut, then
scene B decelerates into place while its content reveals
HOLD = elastic = max(0, D - (IN_BASE + OUT_BASE)); the matched circle
drifts subtly so a longer mount stays alive
OUT_BASE = 0.50s the completed scene fades cleanly
If D is shorter than IN_BASE + OUT_BASE, IN and OUT compress together and
HOLD becomes zero.
Sync point: hard-cut occurs at CUT_AT = 1.40s in the default four-second
duration, exactly frame 42 at 30 fps. Scene A opacity becomes 0 and scene B
opacity becomes 1 at the same timeline instant. There is no crossfade.
Mount contract: this is a template-wrapped sub-composition. The root has no
data-width or data-height, fills its host box, uses container query units,
and registers one paused timeline under the literal match-cut key.
-->
<html
lang="en"
data-composition-id="match-cut"
data-composition-duration="4"
data-composition-variables='[
{ "id": "accent", "type": "enum", "role": "style", "label": "Accent", "description": "Color shared by the matched circles.", "default": "green", "options": [{ "value": "green", "label": "Green" }, { "value": "blue", "label": "Blue" }, { "value": "violet", "label": "Violet" }] },
{ "id": "label_a", "type": "string", "role": "content", "label": "Scene A label", "description": "Text inside the outgoing button.", "default": "Start" },
{ "id": "label_b", "type": "string", "role": "content", "label": "Scene B label", "description": "Result text revealed after the cut.", "default": "Done" }
]'
>
<head>
<meta charset="UTF-8" />
<title>Match Cut</title>
</head>
<body>
<template>
<div id="root" data-composition-id="match-cut" data-duration="4" data-fps="30">
<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
#root {
position: absolute;
inset: 0;
overflow: hidden;
container-type: size;
isolation: isolate;
color: #f8fafc;
font-family: Inter, system-ui, sans-serif;
pointer-events: none;
}
.mc-clip,
.mc-shell,
.mc-scene {
position: absolute;
inset: 0;
overflow: hidden;
}
.mc-scene {
display: grid;
place-items: center;
}
.mc-scene-a {
background: radial-gradient(circle at 50% 50%, #182032 0%, #0b0f18 68%);
}
.mc-scene-b {
background: radial-gradient(circle at 50% 44%, #202b42 0%, #0d121d 72%);
opacity: 0;
}
/* Both actors share this one geometry owner. At the cut, each is
centered at scale 1, so their painted circles match exactly. */
.mc-circle {
display: grid;
place-items: center;
width: 32cqmin;
aspect-ratio: 1;
border-radius: 50%;
background: var(--mc-accent, #4ade80);
box-shadow: 0 3.2cqh 8cqh color-mix(in srgb, var(--mc-accent, #4ade80) 30%, transparent);
transform-origin: 50% 50%;
will-change: transform;
}
.mc-label-a {
display: block;
max-width: 78%;
overflow: hidden;
color: #081018;
font-size: 5.2cqmin;
font-weight: 780;
letter-spacing: -0.035em;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.mc-circle-b {
color: #081018;
}
.mc-check {
width: 34%;
height: 22%;
border-left: 2.4cqmin solid currentColor;
border-bottom: 2.4cqmin solid currentColor;
transform: translateY(-8%) rotate(-45deg);
}
.mc-copy-b {
position: absolute;
top: 69%;
left: 12cqw;
right: 12cqw;
display: flex;
flex-direction: column;
align-items: center;
gap: 1.6cqh;
text-align: center;
}
.mc-kicker,
.mc-label-b {
opacity: 0;
}
.mc-kicker {
color: color-mix(in srgb, var(--mc-accent, #4ade80) 76%, #f8fafc);
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 2.1cqmin;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.mc-label-b {
display: block;
max-width: 74cqw;
overflow: hidden;
color: #f8fafc;
font-size: 6.8cqmin;
font-weight: 760;
letter-spacing: -0.04em;
line-height: 1.05;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
<div
id="match-cut-clip"
class="mc-clip clip"
data-start="0"
data-duration="4"
data-track-index="0"
>
<div class="mc-shell">
<section class="mc-scene mc-scene-a" aria-label="Outgoing scene">
<div class="mc-circle mc-circle-a">
<span class="mc-label-a"></span>
</div>
</section>
<section class="mc-scene mc-scene-b" aria-label="Incoming scene">
<div class="mc-circle mc-circle-b" aria-hidden="true">
<span class="mc-check"></span>
</div>
<div class="mc-copy-b">
<span class="mc-kicker">Match complete</span>
<span class="mc-label-b"></span>
</div>
</section>
</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 shell = root.querySelector(".mc-shell");
var sceneA = root.querySelector(".mc-scene-a");
var sceneB = root.querySelector(".mc-scene-b");
var circleA = root.querySelector(".mc-circle-a");
var circleB = root.querySelector(".mc-circle-b");
var labelA = root.querySelector(".mc-label-a");
var labelB = root.querySelector(".mc-label-b");
var revealB = root.querySelectorAll(".mc-kicker, .mc-label-b");
var vars =
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {};
var accentName =
vars.accent === "blue" || vars.accent === "violet" ? vars.accent : "green";
var accents = { green: "#4ade80", blue: "#60a5fa", violet: "#a78bfa" };
var labelAText = vars.label_a == null ? "Start" : String(vars.label_a);
var labelBText = vars.label_b == null ? "Done" : String(vars.label_b);
root.style.setProperty("--mc-accent", accents[accentName]);
labelA.textContent = labelAText;
labelB.textContent = labelBText;
var IN_BASE = 2.35;
var OUT_BASE = 0.5;
var CUT_AT_BASE = 1.4;
var A_APPROACH_BASE = 0.45;
var B_SETTLE_BASE = 0.65;
var COPY_DELAY_BASE = 0.1;
var COPY_IN_BASE = 0.55;
var duration = Math.max(0.001, parseFloat(root.dataset.duration || "4"));
var envelopeBase = IN_BASE + OUT_BASE;
var phaseScale = duration < envelopeBase ? duration / envelopeBase : 1;
var IN = IN_BASE * phaseScale;
var OUT = OUT_BASE * phaseScale;
var CUT_AT = CUT_AT_BASE * phaseScale;
var A_APPROACH = A_APPROACH_BASE * phaseScale;
var B_SETTLE = B_SETTLE_BASE * phaseScale;
var COPY_DELAY = COPY_DELAY_BASE * phaseScale;
var COPY_IN = COPY_IN_BASE * phaseScale;
var HOLD = Math.max(0, duration - IN - OUT);
var HOLD_START = IN;
var OUT_START = IN + HOLD;
var tl = gsap.timeline({ paused: true });
tl.set(shell, { opacity: 0 }, 0);
tl.set(sceneA, { opacity: 1 }, 0);
tl.set(sceneB, { opacity: 0 }, 0);
tl.set(circleA, { scale: 0.54 }, 0);
tl.set(circleB, { scale: 1, y: 0 }, 0);
tl.set(revealB, { opacity: 0, y: "2.4cqh" }, 0);
tl.to(shell, { opacity: 1, duration: Math.min(0.3, IN), ease: "power2.out" }, 0);
tl.to(circleA, { scale: 0.64, duration: A_APPROACH, ease: "power3.out" }, 0);
tl.to(
circleA,
{
scale: 1,
duration: Math.max(0, CUT_AT - A_APPROACH),
ease: "power4.in",
},
A_APPROACH,
);
// HARD CUT: both opacity states change at the exact same instant.
tl.set(sceneA, { opacity: 0 }, CUT_AT);
tl.set(sceneB, { opacity: 1 }, CUT_AT);
// Scene B starts at the matching geometry and leaves the cut at
// peak speed, then decelerates into its smaller resting pose.
tl.to(circleB, { scale: 0.74, duration: B_SETTLE, ease: "power4.out" }, CUT_AT);
tl.to(
revealB,
{
opacity: 1,
y: 0,
duration: COPY_IN,
stagger: 0.08 * phaseScale,
ease: "power3.out",
},
CUT_AT + COPY_DELAY,
);
if (HOLD > 0) {
tl.to(
circleB,
{
y: "-0.8cqh",
duration: HOLD / 2,
ease: "sine.inOut",
yoyo: true,
repeat: 1,
},
HOLD_START,
);
}
tl.to(shell, { opacity: 0, duration: OUT, ease: "power2.in" }, OUT_START);
tl.seek(0);
window.__timelines = window.__timelines || {};
window.__timelines["match-cut"] = tl;
})();
</script>
</div>
</template>
</body>
</html>
```
</Accordion>
{/* hf:generated-footer */}
Tagged `motion-primitive` `transition` `match-cut` `hard-cut` `continuity`.
## Related topics
- [Browse the complete Catalog](/catalog)
- [Add assets and Catalog items in Studio](/studio/assets-and-blocks)
- [Build a richer composition](/go-further)