Files
hyperframes/docs/catalog/components/kinetic-center-build.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

646 lines
25 KiB
Plaintext

---
title: "Kinetic Center Build"
description: "Words enter from the right and push the growing phrase left until the final line locks centered."
---
import { InstallCommand } from "/snippets/install-command.jsx";
import { VariablesExplorer } from "/snippets/variables-explorer.jsx";
## Install
<InstallCommand command="npx hyperframes add kinetic-center-build" item="kinetic-center-build" />
That writes one file: `compositions/components/kinetic-center-build.html`.
<VariablesExplorer
previewSrc="/public/catalog/components/kinetic-center-build.json"
compositionId="kinetic-center-build"
compositionSrc="compositions/components/kinetic-center-build.html"
variables={[{"id":"text","type":"string","role":"content","label":"Text","description":"Short phrase that builds one word at a time.","default":"Words push left."},{"id":"font_size","type":"number","role":"style","label":"Font size","description":"Maximum text size in pixels. Longer phrases shrink to stay inside the frame.","default":72,"min":36,"max":160,"step":2,"unit":"px"},{"id":"entry_offset","type":"number","role":"motion","label":"Entry offset","description":"Horizontal distance each new word travels before it lands.","default":88,"min":20,"max":160,"step":4,"unit":"px"},{"id":"color","type":"color","role":"style","label":"Color","description":"Text color for the full phrase.","default":"#171717"},{"id":"font_weight","type":"enum","role":"style","label":"Font weight","description":"Weight used by every word in the phrase.","default":"600","options":[{"value":"500","label":"Medium"},{"value":"600","label":"Semibold"},{"value":"700","label":"Bold"},{"value":"800","label":"Extra bold"}]}]}
>
```html kinetic-center-build.html
<!doctype html>
<!--
Kinetic Center Build
Words enter from the right while the existing phrase shifts left, keeping
every intermediate phrase centered until the final line locks in place.
Native HyperFrames port prepared through Hyfrme from:
https://github.com/Remocn/remocn/blob/ea730a20b4ab09430ee7292aebc847c002375151/registry/remocn/kinetic-center-build/index.tsx
MIT License
Copyright (c) 2026 Remocn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-->
<html
lang="en"
data-composition-id="kinetic-center-build"
data-composition-duration="2"
data-composition-variables='[
{ "id": "text", "type": "string", "role": "content", "label": "Text", "description": "Short phrase that builds one word at a time.", "default": "Words push left." },
{ "id": "font_size", "type": "number", "role": "style", "label": "Font size", "description": "Maximum text size in pixels. Longer phrases shrink to stay inside the frame.", "default": 72, "min": 36, "max": 160, "step": 2, "unit": "px" },
{ "id": "entry_offset", "type": "number", "role": "motion", "label": "Entry offset", "description": "Horizontal distance each new word travels before it lands.", "default": 88, "min": 20, "max": 160, "step": 4, "unit": "px" },
{ "id": "color", "type": "color", "role": "style", "label": "Color", "description": "Text color for the full phrase.", "default": "#171717" },
{ "id": "font_weight", "type": "enum", "role": "style", "label": "Font weight", "description": "Weight used by every word in the phrase.", "default": "600", "options": [{ "value": "500", "label": "Medium" }, { "value": "600", "label": "Semibold" }, { "value": "700", "label": "Bold" }, { "value": "800", "label": "Extra bold" }] }
]'
>
<head>
<meta charset="UTF-8" />
<title>Kinetic Center Build</title>
</head>
<body>
<template>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Geist:wght@500;600;700;800&amp;display=block"
/>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
#root {
position: absolute;
inset: 0;
overflow: hidden;
container-type: size;
isolation: isolate;
color: var(--color, #171717);
font-family: var(--font-display, Geist, system-ui, sans-serif);
pointer-events: none;
}
.kcb-clip,
.kcb-stage {
position: absolute;
inset: 0;
overflow: hidden;
}
.kcb-stage {
letter-spacing: -0.03em;
white-space: nowrap;
}
.kcb-word {
--kcb-blur: 0px;
position: absolute;
left: 50%;
top: 50%;
display: inline-block;
color: inherit;
line-height: 1;
white-space: nowrap;
opacity: 0;
filter: blur(var(--kcb-blur));
backface-visibility: hidden;
will-change: transform, filter, opacity;
}
</style>
<div id="root" data-composition-id="kinetic-center-build" data-duration="2" data-fps="30">
<div
id="kcb-clip"
class="kcb-clip clip"
data-start="0"
data-duration="2"
data-track-index="0"
>
<div id="kcb-stage" class="kcb-stage" role="img"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/CustomEase.min.js"></script>
<script>
(function () {
"use strict";
var root = document.getElementById("root");
var stage = document.getElementById("kcb-stage");
var vars =
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {};
function numberBetween(value, fallback, min, max) {
var parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(max, Math.max(min, parsed));
}
var text = typeof vars.text === "string" ? vars.text.trim() : "";
if (!text) text = "Words push left.";
var words = text.split(/\s+/);
var requestedFontSize = numberBetween(vars.font_size, 72, 36, 160);
var entryOffset = numberBetween(vars.entry_offset, 88, 20, 160);
var fontWeight =
vars.font_weight === "500" || vars.font_weight === "700" || vars.font_weight === "800"
? vars.font_weight
: "600";
stage.style.fontWeight = fontWeight;
stage.setAttribute("aria-label", text);
var canvas = document.createElement("canvas");
var context = canvas.getContext("2d");
var measureFont = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
var gapRatio = 10 / 72;
function measure(fontSize) {
if (!context) {
return words.map(function (word) {
return word.length * fontSize * 0.55;
});
}
context.font = fontWeight + " " + fontSize + "px " + measureFont;
return words.map(function (word) {
return context.measureText(word).width;
});
}
var fontSize = requestedFontSize;
var widths = measure(fontSize);
var gap = fontSize * gapRatio;
var fullWidth = widths.reduce(
function (total, width) {
return total + width;
},
gap * Math.max(0, words.length - 1),
);
var maxWidth = Math.max(1, root.clientWidth * 0.9);
if (fullWidth > maxWidth) {
fontSize = Math.max(24, fontSize * (maxWidth / fullWidth));
widths = measure(fontSize);
gap = fontSize * gapRatio;
}
stage.style.fontSize = fontSize + "px";
var positionsAt = [];
for (var count = 1; count <= words.length; count += 1) {
var total = gap * (count - 1);
for (var widthIndex = 0; widthIndex < count; widthIndex += 1) {
total += widths[widthIndex];
}
var cursor = -total / 2;
var positions = [];
for (var positionIndex = 0; positionIndex < count; positionIndex += 1) {
positions.push(cursor + widths[positionIndex] / 2);
cursor += widths[positionIndex] + gap;
}
positionsAt.push(positions);
}
var elements = words.map(function (word, index) {
var element = document.createElement("span");
element.id = "kcb-word-" + index;
element.className = "kcb-word";
element.textContent = word;
element.setAttribute("aria-hidden", "true");
stage.appendChild(element);
return element;
});
gsap.registerPlugin(CustomEase);
var entryEase = CustomEase.create("kcb-entry-ease", "M0,0 C0.2,0.8 0.2,1 1,1");
var duration = Math.max(0.001, parseFloat(root.dataset.duration || "2"));
var firstDuration = 10 / 30;
var pushDuration = 13 / 30;
var authoredBuildDuration =
firstDuration + Math.max(0, words.length - 1) * pushDuration;
var availableBuildDuration = Math.max(0.25, duration - 0.5);
var timingScale = Math.min(1, availableBuildDuration / authoredBuildDuration);
firstDuration *= timingScale;
pushDuration *= timingScale;
var tl = gsap.timeline({ paused: true });
elements.forEach(function (element, index) {
var targetX = positionsAt[index][index];
var start = index === 0 ? 0 : firstDuration + (index - 1) * pushDuration;
var moveDuration = index === 0 ? firstDuration : pushDuration;
var fromX = index === 0 ? targetX : targetX + entryOffset;
var fromY = index === 0 ? 6 : 0;
tl.fromTo(
element,
{
x: fromX,
y: fromY,
xPercent: -50,
yPercent: -50,
scale: 0.992,
opacity: 0,
"--kcb-blur": "3.5px",
},
{
x: targetX,
y: 0,
xPercent: -50,
yPercent: -50,
scale: 1,
opacity: 1,
"--kcb-blur": "0px",
duration: moveDuration,
ease: entryEase,
immediateRender: index === 0,
},
start,
);
if (index === 0) return;
for (var priorIndex = 0; priorIndex < index; priorIndex += 1) {
var priorElement = elements[priorIndex];
var nextX = positionsAt[index][priorIndex];
var blurPeak = start + moveDuration / 2;
tl.to(priorElement, { x: nextX, duration: moveDuration, ease: entryEase }, start);
tl.to(
priorElement,
{ "--kcb-blur": "0.8px", duration: moveDuration / 2, ease: "none" },
start,
);
tl.to(
priorElement,
{ "--kcb-blur": "0px", duration: moveDuration / 2, ease: "none" },
blurPeak,
);
}
});
tl.seek(0);
window.__timelines = window.__timelines || {};
window.__timelines["kinetic-center-build"] = tl;
})();
</script>
</div>
</template>
</body>
</html>
```
</VariablesExplorer>
## Paste it into your composition
Open `compositions/components/kinetic-center-build.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 |
| --- | --- | --- | --- |
| `text` | `Words push left.` | string | Short phrase that builds one word at a time. |
| `font_size` | `72` | 36px to 160px, step 2px | Maximum text size in pixels. Longer phrases shrink to stay inside the frame. |
| `entry_offset` | `88` | 20px to 160px, step 4px | Horizontal distance each new word travels before it lands. |
| `color` | `#171717` | color | Text color for the full phrase. |
| `font_weight` | `600` | `500`, `600`, `700`, `800` | Weight used by every word in the phrase. |
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="kinetic-center-build"
data-composition-src="compositions/components/kinetic-center-build.html"
data-variable-values='{"text":"Words push left.","font_size":72,"entry_offset":88,"color":"#171717","font_weight":"600"}'
></div>
```
## Source
<Accordion title="kinetic-center-build.html">
```html
<!doctype html>
<!--
Kinetic Center Build
Words enter from the right while the existing phrase shifts left, keeping
every intermediate phrase centered until the final line locks in place.
Native HyperFrames port prepared through Hyfrme from:
https://github.com/Remocn/remocn/blob/ea730a20b4ab09430ee7292aebc847c002375151/registry/remocn/kinetic-center-build/index.tsx
MIT License
Copyright (c) 2026 Remocn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-->
<html
lang="en"
data-composition-id="kinetic-center-build"
data-composition-duration="2"
data-composition-variables='[
{ "id": "text", "type": "string", "role": "content", "label": "Text", "description": "Short phrase that builds one word at a time.", "default": "Words push left." },
{ "id": "font_size", "type": "number", "role": "style", "label": "Font size", "description": "Maximum text size in pixels. Longer phrases shrink to stay inside the frame.", "default": 72, "min": 36, "max": 160, "step": 2, "unit": "px" },
{ "id": "entry_offset", "type": "number", "role": "motion", "label": "Entry offset", "description": "Horizontal distance each new word travels before it lands.", "default": 88, "min": 20, "max": 160, "step": 4, "unit": "px" },
{ "id": "color", "type": "color", "role": "style", "label": "Color", "description": "Text color for the full phrase.", "default": "#171717" },
{ "id": "font_weight", "type": "enum", "role": "style", "label": "Font weight", "description": "Weight used by every word in the phrase.", "default": "600", "options": [{ "value": "500", "label": "Medium" }, { "value": "600", "label": "Semibold" }, { "value": "700", "label": "Bold" }, { "value": "800", "label": "Extra bold" }] }
]'
>
<head>
<meta charset="UTF-8" />
<title>Kinetic Center Build</title>
</head>
<body>
<template>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Geist:wght@500;600;700;800&amp;display=block"
/>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
#root {
position: absolute;
inset: 0;
overflow: hidden;
container-type: size;
isolation: isolate;
color: var(--color, #171717);
font-family: var(--font-display, Geist, system-ui, sans-serif);
pointer-events: none;
}
.kcb-clip,
.kcb-stage {
position: absolute;
inset: 0;
overflow: hidden;
}
.kcb-stage {
letter-spacing: -0.03em;
white-space: nowrap;
}
.kcb-word {
--kcb-blur: 0px;
position: absolute;
left: 50%;
top: 50%;
display: inline-block;
color: inherit;
line-height: 1;
white-space: nowrap;
opacity: 0;
filter: blur(var(--kcb-blur));
backface-visibility: hidden;
will-change: transform, filter, opacity;
}
</style>
<div id="root" data-composition-id="kinetic-center-build" data-duration="2" data-fps="30">
<div
id="kcb-clip"
class="kcb-clip clip"
data-start="0"
data-duration="2"
data-track-index="0"
>
<div id="kcb-stage" class="kcb-stage" role="img"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/CustomEase.min.js"></script>
<script>
(function () {
"use strict";
var root = document.getElementById("root");
var stage = document.getElementById("kcb-stage");
var vars =
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {};
function numberBetween(value, fallback, min, max) {
var parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(max, Math.max(min, parsed));
}
var text = typeof vars.text === "string" ? vars.text.trim() : "";
if (!text) text = "Words push left.";
var words = text.split(/\s+/);
var requestedFontSize = numberBetween(vars.font_size, 72, 36, 160);
var entryOffset = numberBetween(vars.entry_offset, 88, 20, 160);
var fontWeight =
vars.font_weight === "500" || vars.font_weight === "700" || vars.font_weight === "800"
? vars.font_weight
: "600";
stage.style.fontWeight = fontWeight;
stage.setAttribute("aria-label", text);
var canvas = document.createElement("canvas");
var context = canvas.getContext("2d");
var measureFont = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
var gapRatio = 10 / 72;
function measure(fontSize) {
if (!context) {
return words.map(function (word) {
return word.length * fontSize * 0.55;
});
}
context.font = fontWeight + " " + fontSize + "px " + measureFont;
return words.map(function (word) {
return context.measureText(word).width;
});
}
var fontSize = requestedFontSize;
var widths = measure(fontSize);
var gap = fontSize * gapRatio;
var fullWidth = widths.reduce(
function (total, width) {
return total + width;
},
gap * Math.max(0, words.length - 1),
);
var maxWidth = Math.max(1, root.clientWidth * 0.9);
if (fullWidth > maxWidth) {
fontSize = Math.max(24, fontSize * (maxWidth / fullWidth));
widths = measure(fontSize);
gap = fontSize * gapRatio;
}
stage.style.fontSize = fontSize + "px";
var positionsAt = [];
for (var count = 1; count <= words.length; count += 1) {
var total = gap * (count - 1);
for (var widthIndex = 0; widthIndex < count; widthIndex += 1) {
total += widths[widthIndex];
}
var cursor = -total / 2;
var positions = [];
for (var positionIndex = 0; positionIndex < count; positionIndex += 1) {
positions.push(cursor + widths[positionIndex] / 2);
cursor += widths[positionIndex] + gap;
}
positionsAt.push(positions);
}
var elements = words.map(function (word, index) {
var element = document.createElement("span");
element.id = "kcb-word-" + index;
element.className = "kcb-word";
element.textContent = word;
element.setAttribute("aria-hidden", "true");
stage.appendChild(element);
return element;
});
gsap.registerPlugin(CustomEase);
var entryEase = CustomEase.create("kcb-entry-ease", "M0,0 C0.2,0.8 0.2,1 1,1");
var duration = Math.max(0.001, parseFloat(root.dataset.duration || "2"));
var firstDuration = 10 / 30;
var pushDuration = 13 / 30;
var authoredBuildDuration =
firstDuration + Math.max(0, words.length - 1) * pushDuration;
var availableBuildDuration = Math.max(0.25, duration - 0.5);
var timingScale = Math.min(1, availableBuildDuration / authoredBuildDuration);
firstDuration *= timingScale;
pushDuration *= timingScale;
var tl = gsap.timeline({ paused: true });
elements.forEach(function (element, index) {
var targetX = positionsAt[index][index];
var start = index === 0 ? 0 : firstDuration + (index - 1) * pushDuration;
var moveDuration = index === 0 ? firstDuration : pushDuration;
var fromX = index === 0 ? targetX : targetX + entryOffset;
var fromY = index === 0 ? 6 : 0;
tl.fromTo(
element,
{
x: fromX,
y: fromY,
xPercent: -50,
yPercent: -50,
scale: 0.992,
opacity: 0,
"--kcb-blur": "3.5px",
},
{
x: targetX,
y: 0,
xPercent: -50,
yPercent: -50,
scale: 1,
opacity: 1,
"--kcb-blur": "0px",
duration: moveDuration,
ease: entryEase,
immediateRender: index === 0,
},
start,
);
if (index === 0) return;
for (var priorIndex = 0; priorIndex < index; priorIndex += 1) {
var priorElement = elements[priorIndex];
var nextX = positionsAt[index][priorIndex];
var blurPeak = start + moveDuration / 2;
tl.to(priorElement, { x: nextX, duration: moveDuration, ease: entryEase }, start);
tl.to(
priorElement,
{ "--kcb-blur": "0.8px", duration: moveDuration / 2, ease: "none" },
start,
);
tl.to(
priorElement,
{ "--kcb-blur": "0px", duration: moveDuration / 2, ease: "none" },
blurPeak,
);
}
});
tl.seek(0);
window.__timelines = window.__timelines || {};
window.__timelines["kinetic-center-build"] = tl;
})();
</script>
</div>
</template>
</body>
</html>
```
</Accordion>
{/* hf:generated-footer */}
Tagged `motion-primitive` `text-effects` `kinetic-type` `reveal` `remocn-port`.
Created by [Remocn](https://github.com/Remocn/remocn).
## Related topics
- [Browse the complete Catalog](/catalog)
- [Add assets and Catalog items in Studio](/studio/assets-and-blocks)
- [Build a richer composition](/go-further)