feat(registry): add 4 community components (#4906)

* feat(registry): add cinemagraph — a still photograph with one living region

* feat(registry): add ai-answer-rank — a mock AI answer that re-ranks to put your brand in it

A 1080x1080, 9s block for answer-engine and AI-visibility marketing. A prompt
types into a chat card, the assistant answers with a ranked list of three picks,
then re-ranks: the promoted brand slides into its slot (brandRank 1-3), the picks
below step down, the third fades out of the list, and the brand row lands with
the one accent highlight and a drawn check. Monochrome placeholder UI; prompt,
lead-in, pick names, brand, rank, accent, ink and ground are variables.

* feat(registry): add caret swap text effect

* feat(registry): add pendulum-swing component

Add a motion-primitive component that hangs content from a fixed pivot and
lets it settle with a damped oscillation, like a hanging sign coming to rest
on its hook.

The swing rides a single unitless custom property (--hf-swing-angle) that the
timeline eases 1 -> 0, so only rotation animates and it stays sub-pixel under
the seek-by-frame capture engine. The demo drives it with a damped-oscillation
ease (a cosine inside an exponential decay, normalised to land exactly upright)
so the sign passes through center several times, each pass smaller, before it
rests.

Four enum variables: pivot (top-center/left/right), amplitude
(gentle/standard/wide), direction (right/left), tone (ink/paper/accent, each a
bg/fg pair to keep AA contrast on any frame). The snippet is recipe-only and
does not own its timeline; its demo carries the same variable declaration.

Ran npx hyperframes lint/validate on both files, regenerated registry.json via
scripts/generate-registry-items.ts and the docs via
scripts/generate-catalog-pages.ts.

* fix(registry): drop hand-added cinemagraph index entry; catalog-publish adds it

* style(registry): format pendulum-swing with oxfmt

* fix(registry): keep pendulum-swing demo caption clear of the swinging sign

* fix(registry): ship the ad-template editing contract for ai-answer-rank

---------

Co-authored-by: Carlos Miranda Levy <carlosmirandalevy@gmail.com>
Co-authored-by: tugrawork-creator <319556794+tugrawork-creator@users.noreply.github.com>
Co-authored-by: Akshar Patel <aksharcommit@gmail.com>
Co-authored-by: rottenpen <12029924+rottenpen@users.noreply.github.com>
This commit is contained in:
Miguel Ángel
2026-10-02 17:14:52 +00:00
committed by GitHub
co-authored by Carlos Miranda Levy tugrawork-creator Akshar Patel rottenpen
parent f16e509832
commit 04acbb4d9e
15 changed files with 2055 additions and 17 deletions
@@ -0,0 +1,24 @@
# AI Answer Rank editing contract
## Surface ownership
This is a generic assistant answer card, not any real AI product. The website brand is the subject of the ranking: the `brand` slot carries its real name, and the re-rank moves it into the answer with the one accent highlight. The other picks are competitors, never the brand itself. The chat card and motion remain template-owned.
## Editable slots
Only defaults declared in `data-composition-variables` are editable:
- `prompt` and `lead`
- `rowA`, `rowB`, and `rowC`, the original picks
- `brand` and `brandRank`, the slot the brand lands in
- `accent`, `ink`, and `ground`
Typed copy is length-locked to within 20% of the original. Pick and brand names stay short enough to fit one row.
## Safe editing mechanics
Call `set_template_variable_defaults` once with the existing variable ids and their new defaults. Do not directly edit or rewrite `index.html` or its `data-composition-variables` attribute; the imported declaration is HTML-entity-encoded JSON and the setter preserves that encoding. Never edit `__template_baseline__.html` or a duplicate composition file. Validate after the setter succeeds.
## Protected
Do not change the card chrome, monogram tiles, skeleton rules, layout, fonts, scene order, duration, timing, easing, typing cadence, or re-rank logic. Website colors may only reach the declared `accent`, `ink`, and `ground` slots; keep `ink` legible on `ground`. If a requested value has no declared variable, leave it unchanged.
+559
View File
@@ -0,0 +1,559 @@
<!doctype html>
<!--
ai-answer-rank: "Is your brand in the AI answer?"
A mock AI chat card for answer-engine and AI-visibility marketing. The
question types into the user's bubble, the assistant answers with a ranked
list of three picks, holds, then re-ranks: the promoted brand slides into
its slot, the rows below it step down, the last one drops out of the top
three, and the brand row lands with the one accent highlight and a check.
The UI is placeholder material: ink at alpha steps over the ground, logos as
rounded-square monograms, descriptions as skeleton rules. Accent lands on
the brand row only.
Rank numbers belong to the slots, not the rows, so a row that moves keeps
the list numbered 1-3 without swapping any text.
-->
<html
lang="en"
data-composition-variables='[
{ "id": "prompt", "type": "string", "role": "content", "label": "Prompt", "description": "The question typed into the user bubble. Up to about 60 characters keeps it on one or two lines.", "default": "What&#39;s the best CRM for a small team?" },
{ "id": "lead", "type": "string", "role": "content", "label": "Answer lead-in", "description": "The assistant line above the ranked list.", "default": "Here are the top picks:" },
{ "id": "rowA", "type": "string", "role": "content", "label": "Pick 1", "description": "First pick in the original answer: a product other than the brand this video promotes.", "portrays": ["competitor_name"], "default": "Brand A" },
{ "id": "rowB", "type": "string", "role": "content", "label": "Pick 2", "description": "Second pick in the original answer: a product other than the brand this video promotes.", "portrays": ["competitor_name"], "default": "Brand B" },
{ "id": "rowC", "type": "string", "role": "content", "label": "Pick 3", "description": "Third pick in the original answer. It drops out of the top three when the brand lands.", "portrays": ["competitor_name"], "default": "Brand C" },
{ "id": "brand", "type": "string", "role": "content", "label": "Brand", "description": "The brand this video promotes, by its real name. It slides into the answer and takes the highlight.", "portrays": ["subject_name"], "default": "Your brand" },
{ "id": "brandRank", "type": "enum", "role": "content", "label": "Brand rank", "description": "The slot the brand lands in. Picks at and below it step down one place.", "portrays": ["recommended_position"], "default": "1", "options": [{ "value": "1", "label": "1st" }, { "value": "2", "label": "2nd" }, { "value": "3", "label": "3rd" }] },
{ "id": "accent", "type": "color", "role": "style", "label": "Accent", "description": "Highlight, monogram and check on the brand row, the one accent element.", "default": "#16a34a" },
{ "id": "ink", "type": "color", "role": "style", "label": "Ink", "description": "Text colour; every placeholder surface is this ink at an alpha step.", "default": "#18181b" },
{ "id": "ground", "type": "color", "role": "style", "label": "Ground", "description": "Chat card surface; the page around it is the ground tinted toward the ink.", "default": "#fcfcfb" }
]'
>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1080, height=1080" />
<title>AI Answer Rank</title>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 1080px;
height: 1080px;
overflow: hidden;
background: #efefee;
}
#aar-root {
--aar-ink: #18181b;
--aar-ground: #fcfcfb;
--aar-accent: #16a34a;
--aar-l1: color-mix(in srgb, var(--aar-ink) 72%, transparent);
--aar-l2: color-mix(in srgb, var(--aar-ink) 45%, transparent);
--aar-l3: color-mix(in srgb, var(--aar-ink) 18%, transparent);
--aar-l4: color-mix(in srgb, var(--aar-ink) 8%, transparent);
--aar-hair: color-mix(in srgb, var(--aar-ink) 14%, transparent);
position: relative;
width: 1080px;
height: 1080px;
overflow: hidden;
background: color-mix(in srgb, var(--aar-ink) 6%, var(--aar-ground));
color: var(--aar-ink);
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}
#aar-scene {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
#aar-card {
width: 920px;
padding: 56px 56px 16px;
border-radius: 44px;
background: var(--aar-ground);
border: 1px solid var(--aar-hair);
box-shadow:
0 2px 6px rgba(0, 0, 0, 0.04),
0 30px 80px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
}
/* the bubble is sized by an invisible copy of the whole prompt, so typing never reflows the card */
#aar-bubble {
position: relative;
align-self: flex-end;
max-width: 720px;
padding: 24px 32px;
border-radius: 32px 32px 10px 32px;
background: var(--aar-l4);
font-size: 32px;
line-height: 44px;
letter-spacing: -0.2px;
transform-origin: 100% 100%;
}
#aar-sizer {
visibility: hidden;
}
#aar-typing {
position: absolute;
left: 32px;
right: 32px;
top: 24px;
}
#aar-caret {
display: inline-block;
width: 3px;
height: 34px;
margin-left: 2px;
border-radius: 2px;
background: var(--aar-ink);
vertical-align: -6px;
}
#aar-answer {
position: relative;
margin-top: 48px;
}
#aar-lead {
display: flex;
align-items: center;
gap: 16px;
height: 48px;
font-size: 34px;
font-weight: 600;
letter-spacing: -0.4px;
}
#aar-star {
flex: 0 0 auto;
width: 30px;
height: 30px;
}
#aar-star path {
fill: var(--aar-ink);
}
.aar-w {
opacity: 0;
}
#aar-thinking {
position: absolute;
left: 4px;
top: 17px;
display: flex;
gap: 10px;
}
.aar-dot {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--aar-l2);
}
/* 40px of runway under slot three, masked, so the pick pushed out fades instead of being cut */
#aar-list {
position: relative;
height: 448px;
margin-top: 28px;
overflow: hidden;
-webkit-mask-image: linear-gradient(to bottom, #000 408px, transparent 448px);
mask-image: linear-gradient(to bottom, #000 408px, transparent 448px);
}
.aar-rank {
position: absolute;
left: 0;
width: 44px;
height: 124px;
display: flex;
align-items: center;
justify-content: center;
font-size: 30px;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--aar-l1);
}
.aar-row {
position: absolute;
left: 68px;
right: 0;
height: 124px;
display: flex;
align-items: center;
gap: 26px;
padding: 0 28px;
border-radius: 28px;
border: 1.5px solid var(--aar-hair);
}
.aar-hl {
position: absolute;
inset: -1.5px;
border-radius: 28px;
border: 3px solid var(--aar-accent);
background: color-mix(in srgb, var(--aar-accent) 9%, transparent);
}
.aar-tile {
position: relative;
flex: 0 0 auto;
width: 68px;
height: 68px;
border-radius: 18px;
background: var(--aar-l1);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.aar-tile-accent {
position: absolute;
inset: 0;
background: var(--aar-accent);
}
.aar-mono {
position: relative;
color: var(--aar-ground);
font-size: 26px;
font-weight: 700;
letter-spacing: 0.5px;
}
.aar-meta {
position: relative;
flex: 1 1 auto;
min-width: 0;
}
.aar-name {
font-size: 36px;
font-weight: 600;
line-height: 44px;
letter-spacing: -0.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.aar-skel {
height: 12px;
margin-top: 12px;
border-radius: 6px;
background: var(--aar-l3);
}
#aar-check {
position: relative;
flex: 0 0 auto;
width: 56px;
height: 56px;
}
#aar-check circle {
fill: var(--aar-accent);
}
#aar-check-path {
fill: none;
stroke: var(--aar-ground);
stroke-width: 5;
stroke-linecap: round;
stroke-linejoin: round;
}
.aar-hidden {
opacity: 0;
visibility: hidden;
}
</style>
</head>
<body>
<div
id="aar-root"
data-composition-id="ai-answer-rank"
data-start="0"
data-width="1080"
data-height="1080"
data-duration="9"
>
<section id="aar-scene" class="clip" data-start="0" data-duration="9" data-track-index="1">
<div id="aar-card" class="aar-hidden">
<div id="aar-bubble" class="aar-hidden">
<span id="aar-sizer">What's the best CRM for a small team?</span>
<div id="aar-typing"><span id="aar-typed"></span><span id="aar-caret"></span></div>
</div>
<div id="aar-answer">
<div id="aar-thinking" class="aar-hidden">
<div class="aar-dot"></div>
<div class="aar-dot"></div>
<div class="aar-dot"></div>
</div>
<div id="aar-lead">
<svg id="aar-star" class="aar-hidden" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M12 0 C12.9 7 17 11.1 24 12 C17 12.9 12.9 17 12 24 C11.1 17 7 12.9 0 12 C7 11.1 11.1 7 12 0 Z"
/>
</svg>
<span id="aar-lead-text">Here are the top picks:</span>
</div>
<div id="aar-list">
<div class="aar-rank aar-hidden" id="aar-rank-1" style="top: 0">1</div>
<div class="aar-rank aar-hidden" id="aar-rank-2" style="top: 142px">2</div>
<div class="aar-rank aar-hidden" id="aar-rank-3" style="top: 284px">3</div>
<div
class="aar-row aar-hidden"
id="aar-row-a"
data-layout-allow-overflow
style="top: 0"
>
<div class="aar-tile"><span class="aar-mono">BA</span></div>
<div class="aar-meta">
<div class="aar-name">Brand A</div>
<div class="aar-skel" style="width: 58%"></div>
</div>
</div>
<div
class="aar-row aar-hidden"
id="aar-row-b"
data-layout-allow-overflow
style="top: 142px"
>
<div class="aar-tile"><span class="aar-mono">BB</span></div>
<div class="aar-meta">
<div class="aar-name">Brand B</div>
<div class="aar-skel" style="width: 44%"></div>
</div>
</div>
<div
class="aar-row aar-hidden"
id="aar-row-c"
data-layout-allow-overflow
style="top: 284px"
>
<div class="aar-tile"><span class="aar-mono">BC</span></div>
<div class="aar-meta">
<div class="aar-name">Brand C</div>
<div class="aar-skel" style="width: 51%"></div>
</div>
</div>
<div
class="aar-row aar-hidden"
id="aar-row-brand"
data-layout-allow-overflow
style="top: 0"
>
<div class="aar-hl aar-hidden" id="aar-hl"></div>
<div class="aar-tile">
<div class="aar-tile-accent aar-hidden" id="aar-tile-accent"></div>
<span class="aar-mono">YB</span>
</div>
<div class="aar-meta">
<div class="aar-name">Your brand</div>
<div class="aar-skel" style="width: 62%"></div>
</div>
<svg id="aar-check" class="aar-hidden" viewBox="0 0 56 56" aria-hidden="true">
<circle cx="28" cy="28" r="28" />
<path id="aar-check-path" d="M17 29 L24.5 36.5 L39 21" />
</svg>
</div>
</div>
</div>
</div>
</section>
</div>
<script>
(function () {
"use strict";
var vars =
window.__hyperframes && typeof window.__hyperframes.getVariables === "function"
? window.__hyperframes.getVariables()
: {};
function text(value, fallback, max) {
if (typeof value !== "string") return fallback;
var t = value.trim();
if (!t) return fallback;
return t.length > max ? t.slice(0, max) : t;
}
function color(value, fallback) {
if (typeof value !== "string") return fallback;
var c = value.trim();
return c && CSS.supports("color", c) ? c : fallback;
}
function monogram(name) {
var words = name.split(/\s+/).filter(Boolean);
var letters =
words.length > 1 ? words[0][0] + words[words.length - 1][0] : name.slice(0, 1);
return letters.toUpperCase();
}
var root = document.getElementById("aar-root");
root.style.setProperty("--aar-ink", color(vars.ink, "#18181b"));
root.style.setProperty("--aar-ground", color(vars.ground, "#fcfcfb"));
root.style.setProperty("--aar-accent", color(vars.accent, "#16a34a"));
var prompt = text(vars.prompt, "What's the best CRM for a small team?", 90);
var lead = text(vars.lead, "Here are the top picks:", 48);
var names = [
text(vars.rowA, "Brand A", 32),
text(vars.rowB, "Brand B", 32),
text(vars.rowC, "Brand C", 32),
];
var brand = text(vars.brand, "Your brand", 32);
var slot = ["1", "2", "3"].indexOf(String(vars.brandRank));
if (slot < 0) slot = 0;
var SLOT = 142;
var rows = ["#aar-row-a", "#aar-row-b", "#aar-row-c"].map(function (sel, i) {
var el = document.querySelector(sel);
el.querySelector(".aar-name").textContent = names[i];
el.querySelector(".aar-mono").textContent = monogram(names[i]);
return el;
});
var brandRow = document.getElementById("aar-row-brand");
brandRow.querySelector(".aar-name").textContent = brand;
brandRow.querySelector(".aar-mono").textContent = monogram(brand);
brandRow.style.top = slot * SLOT + "px";
document.getElementById("aar-sizer").textContent = prompt;
var leadEl = document.getElementById("aar-lead-text");
leadEl.textContent = "";
var words = lead.split(/\s+/).map(function (word, i) {
if (i > 0) leadEl.appendChild(document.createTextNode(" "));
var span = document.createElement("span");
span.className = "aar-w";
span.textContent = word;
leadEl.appendChild(span);
return span;
});
var checkPath = document.getElementById("aar-check-path");
var checkLen = checkPath.getTotalLength();
checkPath.style.strokeDasharray = String(checkLen);
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
tl.fromTo(
"#aar-card",
{ autoAlpha: 0, y: 40 },
{ autoAlpha: 1, y: 0, duration: 0.6, ease: "power3.out" },
0.1,
);
tl.fromTo(
"#aar-bubble",
{ autoAlpha: 0, scale: 0.9 },
{ autoAlpha: 1, scale: 1, duration: 0.3, ease: "back.out(1.6)" },
0.5,
);
/* uneven keystrokes read as a person typing; the budget keeps long prompts inside 9s */
var KEY_GAPS = [
0.05, 0.035, 0.06, 0.04, 0.03, 0.055, 0.045, 0.07, 0.035, 0.05, 0.04, 0.065,
];
var raw = 0;
for (var n = 1; n < prompt.length; n += 1) raw += KEY_GAPS[n % KEY_GAPS.length];
var squeeze = Math.min(1, 1.8 / Math.max(raw, 0.001));
var TYPE_START = 0.8;
var t = TYPE_START;
tl.set("#aar-typed", { textContent: "" }, 0.001);
tl.set("#aar-caret", { autoAlpha: 0 }, 0.001);
tl.set("#aar-caret", { autoAlpha: 1 }, TYPE_START - 0.15);
for (var c = 1; c <= prompt.length; c += 1) {
tl.set("#aar-typed", { textContent: prompt.slice(0, c) }, t);
t += KEY_GAPS[c % KEY_GAPS.length] * squeeze;
}
var typeEnd = t;
tl.set("#aar-caret", { autoAlpha: 0 }, typeEnd + 0.2);
var THINK = typeEnd + 0.4;
tl.fromTo(
"#aar-thinking",
{ autoAlpha: 0 },
{ autoAlpha: 1, duration: 0.15, ease: "none" },
THINK,
);
gsap.utils.toArray(".aar-dot").forEach(function (dot, i) {
for (var b = 0; b < 2; b += 1) {
var at = THINK + 0.1 + b * 0.32 + i * 0.08;
tl.to(dot, { y: -9, duration: 0.14, ease: "sine.out" }, at);
tl.to(dot, { y: 0, duration: 0.14, ease: "sine.in" }, at + 0.14);
}
});
var LEAD = THINK + 0.85;
tl.to("#aar-thinking", { autoAlpha: 0, duration: 0.12, ease: "none" }, LEAD - 0.12);
tl.fromTo(
"#aar-star",
{ autoAlpha: 0, scale: 0.3, rotation: -90 },
{ autoAlpha: 1, scale: 1, rotation: 0, duration: 0.45, ease: "back.out(2)" },
LEAD,
);
words.forEach(function (w, i) {
tl.set(w, { opacity: 1 }, LEAD + 0.12 + i * 0.07);
});
var ROWS = LEAD + 0.25 + words.length * 0.07;
rows.forEach(function (row, i) {
var at = ROWS + i * 0.16;
tl.fromTo(
"#aar-rank-" + (i + 1),
{ autoAlpha: 0 },
{ autoAlpha: 1, duration: 0.3, ease: "none" },
at,
);
tl.fromTo(
row,
{ autoAlpha: 0, y: 24 },
{ autoAlpha: 1, y: 0, duration: 0.45, ease: "power3.out" },
at,
);
});
/* picks step down rather than trade places, so the eye can follow each one to its new rank */
var RERANK = ROWS + 0.32 + 0.45 + 0.95;
rows.forEach(function (row, i) {
if (i < slot) return;
tl.to(row, { y: SLOT, duration: 0.55, ease: "power3.inOut" }, RERANK);
if (i === 2) tl.to(row, { autoAlpha: 0, duration: 0.4, ease: "power1.in" }, RERANK + 0.1);
});
/* the brand waits until its slot is mostly clear, so it never lands on the row leaving it */
tl.fromTo(
brandRow,
{ autoAlpha: 0, x: 96 },
{ autoAlpha: 1, x: 0, duration: 0.55, ease: "power3.out" },
RERANK + 0.34,
);
var LAND = RERANK + 0.9;
tl.fromTo(
"#aar-hl",
{ autoAlpha: 0 },
{ autoAlpha: 1, duration: 0.3, ease: "power1.out" },
LAND,
);
tl.fromTo(
"#aar-tile-accent",
{ autoAlpha: 0 },
{ autoAlpha: 1, duration: 0.3, ease: "power1.out" },
LAND,
);
tl.fromTo(
"#aar-check",
{ autoAlpha: 0, scale: 0.4 },
{ autoAlpha: 1, scale: 1, duration: 0.42, ease: "back.out(2.2)" },
LAND + 0.05,
);
tl.fromTo(
checkPath,
{ strokeDashoffset: checkLen },
{ strokeDashoffset: 0, duration: 0.34, ease: "power2.out" },
LAND + 0.2,
);
tl.set({}, {}, 9);
window.__timelines["ai-answer-rank"] = tl;
})();
</script>
</body>
</html>
@@ -0,0 +1,127 @@
{
"$schema": "https://hyperframes.heygen.com/schema/registry-item.json",
"name": "ai-answer-rank",
"type": "hyperframes:block",
"title": "AI Answer Rank",
"description": "Is your brand in the AI answer? A question types into a chat card, the assistant answers with a ranked list of three picks, then re-ranks: the promoted brand slides into its slot, the picks below step down, and the brand row lands with the one accent highlight and a check. For answer-engine and AI-visibility marketing",
"tags": ["mock-ui", "ai", "chat", "ranking", "list", "marketing", "ad-template", "square"],
"author": "Tugra Avci",
"dimensions": {
"width": 1080,
"height": 1080
},
"duration": 9,
"variables": [
{
"id": "prompt",
"type": "string",
"role": "content",
"label": "Prompt",
"description": "The question typed into the user bubble. Up to about 60 characters keeps it on one or two lines.",
"default": "What's the best CRM for a small team?"
},
{
"id": "lead",
"type": "string",
"role": "content",
"label": "Answer lead-in",
"description": "The assistant line above the ranked list.",
"default": "Here are the top picks:"
},
{
"id": "rowA",
"type": "string",
"role": "content",
"label": "Pick 1",
"description": "First pick in the original answer: a product other than the brand this video promotes.",
"portrays": ["competitor_name"],
"default": "Brand A"
},
{
"id": "rowB",
"type": "string",
"role": "content",
"label": "Pick 2",
"description": "Second pick in the original answer: a product other than the brand this video promotes.",
"portrays": ["competitor_name"],
"default": "Brand B"
},
{
"id": "rowC",
"type": "string",
"role": "content",
"label": "Pick 3",
"description": "Third pick in the original answer. It drops out of the top three when the brand lands.",
"portrays": ["competitor_name"],
"default": "Brand C"
},
{
"id": "brand",
"type": "string",
"role": "content",
"label": "Brand",
"description": "The brand this video promotes, by its real name. It slides into the answer and takes the highlight.",
"portrays": ["subject_name"],
"default": "Your brand"
},
{
"id": "brandRank",
"type": "enum",
"role": "content",
"label": "Brand rank",
"description": "The slot the brand lands in. Picks at and below it step down one place.",
"portrays": ["recommended_position"],
"default": "1",
"options": [
{
"value": "1",
"label": "1st"
},
{
"value": "2",
"label": "2nd"
},
{
"value": "3",
"label": "3rd"
}
]
},
{
"id": "accent",
"type": "color",
"role": "style",
"label": "Accent",
"description": "Highlight, monogram and check on the brand row, the one accent element.",
"default": "#16a34a"
},
{
"id": "ink",
"type": "color",
"role": "style",
"label": "Ink",
"description": "Text colour; every placeholder surface is this ink at an alpha step.",
"default": "#18181b"
},
{
"id": "ground",
"type": "color",
"role": "style",
"label": "Ground",
"description": "Chat card surface; the page around it is the ground tinted toward the ink.",
"default": "#fcfcfb"
}
],
"files": [
{
"path": "ai-answer-rank.html",
"target": "compositions/ai-answer-rank.html",
"type": "hyperframes:composition"
},
{
"path": "TEMPLATE.md",
"target": "TEMPLATE.md",
"type": "hyperframes:asset"
}
]
}
+298
View File
@@ -0,0 +1,298 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<!--
cinemagraph — a still photograph with ONE living region.
The frame is a frozen scene; a single masked window keeps moving — the classic
cinemagraph gesture: the camera stops, the world keeps breathing. The living
region here is the steam over a cup, drawn deterministically on a canvas from
the seek time alone, with a period that divides the duration, so the last
frame matches the first and the render loops seamlessly.
Using it with real media: keep the structure and swap the layers —
1. Put your looping <video> (or the animated source) as the BASE layer.
2. Put a freeze-frame still of it (an <img>) ABOVE, full-frame.
3. Move #cmg-window (the feathered mask) onto the still so the window
reveals the video beneath, and delete the placeholder scene + canvas.
One living region per frame. Two is a gif.
-->
<style>
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #0b0e14;
overflow: hidden;
}
#root-cinemagraph {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
background: #0b0e14;
--hf-ink: #f8fafc;
}
/* ---- the still photograph (placeholder scene, monochrome ink ramp) ---- */
.cmg-photo {
position: absolute;
inset: 0;
}
/* window light falling across the scene */
.cmg-lightfall {
position: absolute;
inset: 0;
background: linear-gradient(
115deg,
color-mix(in srgb, var(--hf-ink) 8%, transparent) 0%,
transparent 46%
);
}
/* table surface */
.cmg-table {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 320px;
background: color-mix(in srgb, var(--hf-ink) 8%, transparent);
border-top: 1px solid color-mix(in srgb, var(--hf-ink) 14%, transparent);
}
/* window frame in the far wall */
.cmg-pane {
position: absolute;
left: 1210px;
top: 130px;
width: 420px;
height: 520px;
background: color-mix(in srgb, var(--hf-ink) 18%, transparent);
border: 1px solid color-mix(in srgb, var(--hf-ink) 14%, transparent);
}
.cmg-pane::before,
.cmg-pane::after {
content: "";
position: absolute;
background: color-mix(in srgb, var(--hf-ink) 14%, transparent);
}
.cmg-pane::before {
left: 50%;
top: 0;
bottom: 0;
width: 1px;
}
.cmg-pane::after {
top: 50%;
left: 0;
right: 0;
height: 1px;
}
/* saucer + cup, the photograph's subject */
.cmg-saucer {
position: absolute;
left: 760px;
bottom: 236px;
width: 400px;
height: 56px;
border-radius: 50%;
background: color-mix(in srgb, var(--hf-ink) 45%, transparent);
}
.cmg-cup {
position: absolute;
left: 830px;
bottom: 262px;
width: 260px;
height: 190px;
border-radius: 0 0 118px 118px;
background: color-mix(in srgb, var(--hf-ink) 72%, transparent);
}
.cmg-cup::after {
content: "";
position: absolute;
right: -64px;
top: 18px;
width: 88px;
height: 96px;
border: 22px solid color-mix(in srgb, var(--hf-ink) 72%, transparent);
border-radius: 50%;
}
.cmg-rim {
position: absolute;
left: 822px;
bottom: 436px;
width: 276px;
height: 34px;
border-radius: 50%;
background: color-mix(in srgb, var(--hf-ink) 45%, transparent);
}
/* a closed book beside the cup */
.cmg-book {
position: absolute;
left: 1180px;
bottom: 250px;
width: 330px;
height: 44px;
background: color-mix(in srgb, var(--hf-ink) 18%, transparent);
border: 1px solid color-mix(in srgb, var(--hf-ink) 14%, transparent);
transform: rotate(-2deg);
}
.cmg-book::after {
content: "";
position: absolute;
left: 14px;
right: 14px;
top: 12px;
height: 1px;
background: color-mix(in srgb, var(--hf-ink) 14%, transparent);
}
/* skeleton caption lines, bottom left — content lives here */
.cmg-line {
position: absolute;
left: 160px;
height: 22px;
border-radius: 11px;
background: color-mix(in srgb, var(--hf-ink) 18%, transparent);
}
/* ---- the ONE living region: a feathered window where steam moves ---- */
#cmg-window {
position: absolute;
left: 790px;
bottom: 430px;
width: 340px;
height: 420px;
-webkit-mask-image: radial-gradient(ellipse 50% 50% at 50% 55%, #000 55%, transparent 78%);
mask-image: radial-gradient(ellipse 50% 50% at 50% 55%, #000 55%, transparent 78%);
}
#cmg-canvas {
position: absolute;
inset: 0;
width: 340px;
height: 420px;
}
/* still vignette over everything, part of the photograph */
.cmg-vignette {
position: absolute;
inset: 0;
background: radial-gradient(
ellipse 78% 72% at 50% 46%,
transparent 62%,
rgba(4, 6, 10, 0.5) 100%
);
pointer-events: none;
}
</style>
</head>
<body>
<div
id="root-cinemagraph"
data-composition-id="cinemagraph"
data-start="0"
data-duration="10"
data-width="1920"
data-height="1080"
>
<div class="cmg-photo" id="cmg-photo">
<div class="cmg-lightfall"></div>
<div class="cmg-pane"></div>
<div class="cmg-table"></div>
<div class="cmg-saucer"></div>
<div class="cmg-cup"></div>
<div class="cmg-rim"></div>
<div class="cmg-book"></div>
<div class="cmg-line" style="bottom: 150px; width: 460px"></div>
<div class="cmg-line" style="bottom: 108px; width: 300px"></div>
</div>
<div id="cmg-window">
<canvas id="cmg-canvas" width="340" height="420"></canvas>
</div>
<div class="cmg-vignette"></div>
<div
id="drv-cinemagraph"
class="clip"
data-start="0"
data-duration="10"
data-track-index="0"
style="position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none"
></div>
</div>
<script>
(function () {
window.__timelines = window.__timelines || {};
// Seeded PRNG — never Math.random()
function mulberry32(a) {
return function () {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rng = mulberry32(1897);
var DUR = 10;
// Each wisp cycles an integer number of times over DUR, so the frame at
// t = DUR equals the frame at t = 0 and the render loops seamlessly.
var WISPS = [];
for (var i = 0; i < 14; i++) {
WISPS.push({
x0: 90 + rng() * 160, // birth x inside the window
cycles: 1 + Math.floor(rng() * 2), // 1 or 2 full rises over DUR
phase: rng(), // where in its cycle it starts
sway: 14 + rng() * 26, // horizontal sway amplitude
swayFreq: 1 + Math.floor(rng() * 2), // integer — keeps the loop seam clean
width: 10 + rng() * 22, // wisp thickness
rise: 300 + rng() * 80, // how far it climbs before dissolving
alpha: 0.05 + rng() * 0.09,
});
}
var canvas = document.getElementById("cmg-canvas");
var ctx = canvas.getContext("2d");
var W = 340,
H = 420;
// State proxy — GSAP animates t, the render function reads it.
var st = { t: 0 };
function renderScene() {
ctx.clearRect(0, 0, W, H);
for (var i = 0; i < WISPS.length; i++) {
var w = WISPS[i];
// p in [0,1): this wisp's position in its own rise cycle
var p = (st.t / DUR) * w.cycles + w.phase;
p = p - Math.floor(p);
var y = H - 40 - p * w.rise;
var sway = Math.sin(2 * Math.PI * (p * w.swayFreq + w.phase)) * w.sway * p;
var x = w.x0 + sway;
// born faint, fullest mid-rise, dissolves at the top
var a = w.alpha * Math.sin(Math.PI * p);
var r = w.width * (0.7 + p * 1.6);
var g = ctx.createRadialGradient(x, y, 0, x, y, r * 2.4);
g.addColorStop(0, "rgba(248,250,252," + a.toFixed(4) + ")");
g.addColorStop(1, "rgba(248,250,252,0)");
ctx.fillStyle = g;
ctx.beginPath();
ctx.ellipse(x, y, r * 1.1, r * 2.2, sway * 0.004, 0, 2 * Math.PI);
ctx.fill();
}
}
var tl = gsap.timeline({ paused: true });
tl.to(st, { t: DUR, duration: DUR, ease: "none" }, 0);
window.__timelines["cinemagraph"] = tl;
// Render from the timeline only — no requestAnimationFrame.
tl.eventCallback("onUpdate", renderScene);
renderScene();
})();
</script>
</body>
</html>
@@ -0,0 +1,17 @@
{
"$schema": "https://hyperframes.heygen.com/schema/registry-item.json",
"name": "cinemagraph",
"type": "hyperframes:block",
"title": "Cinemagraph",
"description": "A still photograph with one living region: the frame is frozen while a single feathered window keeps moving - here, steam rising over a cup - drawn from the seek time alone with a period that divides the duration, so the render loops seamlessly. Swap the placeholder scene for a freeze-frame still masked over a looping video to cinemagraph real footage.",
"dimensions": { "width": 1920, "height": 1080 },
"duration": 10,
"tags": ["effect", "cinemagraph", "photo", "loop", "freeze-frame"],
"files": [
{
"path": "cinemagraph.html",
"target": "compositions/cinemagraph.html",
"type": "hyperframes:composition"
}
]
}
Binary file not shown.
@@ -0,0 +1,93 @@
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font.git)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+355
View File
@@ -0,0 +1,355 @@
<!doctype html>
<!--
Caret Swap
Mount compositions/components/caret-swap/caret-swap.html with
data-composition-id="caret-swap". The transparent component fills its host.
The bundled font must remain beside this file.
The reference's expand, collapse, pause and accelerating typing use a 30fps
clock. Longer hosts extend the final hold; shorter hosts compress the phases
together so the replacement is complete before the end. Text is measured
after fonts load, and both phrases shrink together to stay inside the host.
This is a single-line effect; line breaks in either phrase become spaces.
Native HyperFrames port prepared through Hyfrme from:
https://github.com/Remocn/remocn/blob/3e03565f5c0001e143c2ed941eea7c3181f13260/registry/remocn/caret-swap/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="caret-swap"
data-composition-duration="3.3333333333333335"
data-composition-variables='[
{"id":"from_text","type":"string","role":"content","label":"From text","description":"The phrase covered by the expanding caret.","default":"Executing it?"},
{"id":"to_text","type":"string","role":"content","label":"To text","description":"The replacement phrase, typed one character at a time.","default":"That&#39;s where everyone quits"},
{"id":"font_size","type":"number","role":"style","label":"Font size","description":"Maximum size in pixels. Both phrases shrink together to fit the host.","default":72,"min":36,"max":160,"step":2,"unit":"px"},
{"id":"font_weight","type":"enum","role":"style","label":"Font weight","description":"Weight used by both phrases. Every option is included in the bundled Geist font.","default":"400","options":[{"value":"400","label":"Regular"},{"value":"500","label":"Medium"},{"value":"600","label":"Semibold"},{"value":"700","label":"Bold"}]},
{"id":"color","type":"color","role":"style","label":"Text color","description":"Color of both phrases.","default":"#171717"},
{"id":"caret_color","type":"color","role":"style","label":"Caret top","description":"Top color of the caret gradient.","default":"#FF4DAA"},
{"id":"caret_color_end","type":"color","role":"style","label":"Caret bottom","description":"Bottom color of the caret gradient.","default":"#8A5CF6"}
]'
>
<head>
<meta charset="UTF-8" />
<title>Caret Swap</title>
</head>
<body>
<template>
<style>
@font-face {
font-family: "Caret Swap Geist";
src: url("./Geist-Latin.woff2") format("woff2");
font-style: normal;
font-weight: 100 900;
font-display: block;
}
#root,
#root * {
box-sizing: border-box;
}
#root {
position: absolute;
inset: 0;
overflow: hidden;
isolation: isolate;
pointer-events: none;
color: var(--color, #171717);
font-family: var(--font-display, "Caret Swap Geist", system-ui, sans-serif);
}
.cs-clip {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.cs-stage {
position: relative;
display: inline-flex;
align-items: center;
white-space: pre;
font-size: 72px;
font-weight: 400;
line-height: 1.15;
letter-spacing: -0.02em;
opacity: 0;
}
.cs-source,
.cs-target-text {
display: inline-block;
white-space: pre;
}
.cs-spacer {
flex-shrink: 0;
width: 0.08em;
height: 1.1em;
margin-left: 0.06em;
}
.cs-cover,
.cs-caret {
position: absolute;
top: 50%;
height: 1.1em;
border-radius: 0.03em;
background: linear-gradient(
180deg,
var(--caret_color, #ff4daa),
var(--caret_color_end, #8a5cf6)
);
}
.cs-cover {
left: 0;
width: 100%;
--cs-mask-stop: 100%;
mask-image: linear-gradient(90deg, black var(--cs-mask-stop), transparent 100%);
}
.cs-target {
position: absolute;
left: 0;
top: 50%;
display: inline-flex;
align-items: center;
}
.cs-caret {
left: 0;
width: 0.08em;
margin-left: 0.06em;
}
</style>
<div
id="root"
data-composition-id="caret-swap"
data-duration="3.3333333333333335"
data-fps="30"
>
<div
id="cs-clip"
class="cs-clip clip"
data-start="0"
data-duration="3.3333333333333335"
data-track-index="0"
>
<div id="cs-stage" class="cs-stage" role="img">
<span
id="cs-source"
class="cs-source"
aria-hidden="true"
data-layout-allow-occlusion
></span>
<span class="cs-spacer" aria-hidden="true"></span>
<span id="cs-cover" class="cs-cover" aria-hidden="true"></span>
<span id="cs-target" class="cs-target" aria-hidden="true">
<span id="cs-target-text" class="cs-target-text"></span>
<span id="cs-caret" class="cs-caret"></span>
</span>
</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("cs-stage");
var source = document.getElementById("cs-source");
var target = document.getElementById("cs-target");
var targetText = document.getElementById("cs-target-text");
var caret = document.getElementById("cs-caret");
var cover = document.getElementById("cs-cover");
// Read host timing before the runtime flattens its attributes.
var mount = root.closest("[data-composition-id]") || root;
var duration = Math.max(
0.001,
Number(mount.dataset.duration || mount.dataset.hfAuthoredDuration) || 100 / 30,
);
document.getElementById("cs-clip").dataset.duration = String(duration);
var vars =
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {};
var fromText = typeof vars.from_text === "string" ? vars.from_text : "Executing it?";
var toText =
typeof vars.to_text === "string" ? vars.to_text : "That's where everyone quits";
fromText = fromText.replace(/[\r\n\u2028\u2029]+/g, " ");
toText = toText.replace(/[\r\n\u2028\u2029]+/g, " ");
var fontSize = Number(vars.font_size);
fontSize = Number.isFinite(fontSize) ? Math.min(160, Math.max(36, fontSize)) : 72;
var fontWeight = ["400", "500", "600", "700"].includes(vars.font_weight)
? vars.font_weight
: "400";
stage.style.fontSize = fontSize + "px";
stage.style.fontWeight = fontWeight;
stage.setAttribute("aria-label", fromText + " → " + toText);
source.textContent = fromText;
var segments = Array.from(
new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(toText),
);
var phrases = segments.map(function (part) {
return toText.slice(0, part.index + part.segment.length);
});
function geometry() {
var anchorWidth = stage.getBoundingClientRect().width;
targetText.textContent = toText;
var targetWidth = targetText.getBoundingClientRect().width;
var caretWidth = fontSize * 0.08;
var gap = fontSize * 0.06;
var shift = (anchorWidth - targetWidth - caretWidth - gap) / 2;
var prefixes = phrases.map(function (phrase) {
targetText.textContent = phrase;
return targetText.getBoundingClientRect().width;
});
targetText.textContent = toText;
var halfWidth = anchorWidth / 2;
prefixes.forEach(function (prefix, index) {
var start = -anchorWidth / 2 + (shift * (index + 1)) / phrases.length;
halfWidth = Math.max(
halfWidth,
Math.abs(start),
Math.abs(start + prefix + gap + caretWidth),
);
});
return {
anchorWidth: anchorWidth,
caretWidth: caretWidth,
shift: shift,
prefixes: prefixes,
width: halfWidth * 2,
};
}
function typeElapsed(count) {
return (1 - Math.pow(0.96, count)) / 0.04;
}
document.fonts.ready.then(function () {
var layout = geometry();
var fit = Math.min(
1,
(root.clientWidth * 0.9) / Math.max(1, layout.width),
(root.clientHeight * 0.8) / (fontSize * 1.15),
);
fontSize *= fit;
stage.style.fontSize = fontSize + "px";
layout = geometry();
var typeEnd = 40 + typeElapsed(phrases.length);
var timingScale = Math.min(1, (duration * 30) / (typeEnd + 8));
function at(frame) {
return (frame / 30) * timingScale;
}
gsap.registerPlugin(CustomEase);
var expandEase = CustomEase.create("cs-expand", "M0,0 C0.6,0 0.8,0.4 1,1");
var collapseEase = CustomEase.create("cs-collapse", "M0,0 C0.2,0.7 0.3,1 1,1");
gsap.set(source, { opacity: 1 });
gsap.set(cover, {
yPercent: -50,
width: layout.caretWidth,
x: layout.anchorWidth - layout.caretWidth,
opacity: 0,
"--cs-mask-stop": "100%",
});
gsap.set(target, { yPercent: -50, x: 0, opacity: 0 });
gsap.set(caret, { yPercent: -50, x: 0, opacity: 1 });
gsap.set(targetText, { textContent: "" });
gsap.set(stage, { opacity: 1 });
var tl = gsap.timeline({ paused: true });
tl.set(cover, { opacity: 1 }, at(13));
tl.fromTo(
cover,
{
width: layout.caretWidth,
x: layout.anchorWidth - layout.caretWidth,
"--cs-mask-stop": "100%",
},
{
width: layout.anchorWidth,
x: 0,
"--cs-mask-stop": "10%",
duration: at(6),
ease: expandEase,
immediateRender: false,
},
at(20),
);
tl.set(source, { opacity: 0 }, at(26));
tl.fromTo(
cover,
{ width: layout.anchorWidth, "--cs-mask-stop": "10%" },
{
width: layout.caretWidth,
"--cs-mask-stop": "100%",
duration: at(7),
ease: collapseEase,
immediateRender: false,
},
at(26),
);
tl.set(cover, { opacity: 0 }, at(40));
tl.set(target, { opacity: 1 }, at(40));
phrases.forEach(function (phrase, index) {
var time = at(40 + typeElapsed(index));
tl.set(targetText, { textContent: phrase }, time);
tl.set(caret, { x: layout.prefixes[index] }, time);
tl.set(target, { x: (layout.shift * (index + 1)) / phrases.length }, time);
});
if (!phrases.length) tl.set(target, { x: layout.shift }, at(40));
for (
var frame = typeEnd + 8, blink = 0;
at(frame) < duration;
frame += 8, blink += 1
) {
tl.set(caret, { opacity: blink % 2 }, at(frame));
}
window.__timelines = window.__timelines || {};
window.__timelines["caret-swap"] = tl;
});
})();
</script>
</div>
</template>
</body>
</html>
+71
View File
@@ -0,0 +1,71 @@
<!doctype html>
<!-- Caret Swap demo. Defaults and 100-frame timing follow the pinned Hyfrme reference. -->
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1280, height=720" />
<title>Caret Swap Demo</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body,
#root {
width: 1280px;
height: 720px;
overflow: hidden;
}
#root {
position: relative;
}
.cs-demo-background,
.cs-demo-mount {
position: absolute;
inset: 0;
width: 1280px;
height: 720px;
}
.cs-demo-background {
background: #fdf6f0;
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="caret-swap-demo"
data-start="0"
data-width="1280"
data-height="720"
data-duration="3.3333333333333335"
data-fps="30"
>
<div
id="cs-demo-background"
class="cs-demo-background clip"
data-start="0"
data-duration="3.3333333333333335"
data-track-index="0"
></div>
<div
id="cs-demo-mount"
class="cs-demo-mount clip"
data-composition-id="caret-swap"
data-composition-src="./caret-swap.html"
data-start="0"
data-duration="3.3333333333333335"
data-track-index="1"
data-width="1280"
data-height="720"
></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["caret-swap-demo"] = gsap.timeline({ paused: true });
</script>
</body>
</html>
@@ -0,0 +1,119 @@
{
"$schema": "https://hyperframes.heygen.com/schema/registry-item.json",
"name": "caret-swap",
"type": "hyperframes:component",
"title": "Caret Swap",
"description": "A gradient caret expands over one phrase, collapses to the left, then types its replacement with a blinking caret.",
"tags": ["motion-primitive", "text-effects", "typography", "swap", "typewriter", "remocn-port"],
"jobs": ["emphasize"],
"family": "text-effects",
"profile": "caret-swap",
"author": "Remocn",
"authorUrl": "https://github.com/Remocn/remocn",
"license": "MIT AND OFL-1.1",
"syncPoints": [
{
"id": "wipe-start",
"phase": "in",
"offset": 0.6666666666666666,
"description": "The caret starts covering the first phrase in the default 100-frame demo. Shorter hosts compress the phases together."
}
],
"files": [
{
"path": "caret-swap.html",
"target": "compositions/components/caret-swap/caret-swap.html",
"type": "hyperframes:snippet"
},
{
"path": "Geist-Latin.woff2",
"target": "compositions/components/caret-swap/Geist-Latin.woff2",
"type": "hyperframes:asset"
},
{
"path": "Geist-OFL.txt",
"target": "compositions/components/caret-swap/Geist-OFL.txt",
"type": "hyperframes:asset"
}
],
"variables": [
{
"id": "from_text",
"type": "string",
"role": "content",
"label": "From text",
"description": "The phrase covered by the expanding caret.",
"default": "Executing it?"
},
{
"id": "to_text",
"type": "string",
"role": "content",
"label": "To text",
"description": "The replacement phrase, typed one character at a time.",
"default": "That's where everyone quits"
},
{
"id": "font_size",
"type": "number",
"role": "style",
"label": "Font size",
"description": "Maximum size in pixels. Both phrases shrink together to fit the host.",
"default": 72,
"min": 36,
"max": 160,
"step": 2,
"unit": "px"
},
{
"id": "font_weight",
"type": "enum",
"role": "style",
"label": "Font weight",
"description": "Weight used by both phrases. Every option is included in the bundled Geist font.",
"default": "400",
"options": [
{
"value": "400",
"label": "Regular"
},
{
"value": "500",
"label": "Medium"
},
{
"value": "600",
"label": "Semibold"
},
{
"value": "700",
"label": "Bold"
}
]
},
{
"id": "color",
"type": "color",
"role": "style",
"label": "Text color",
"description": "Color of both phrases.",
"default": "#171717"
},
{
"id": "caret_color",
"type": "color",
"role": "style",
"label": "Caret top",
"description": "Top color of the caret gradient.",
"default": "#FF4DAA"
},
{
"id": "caret_color_end",
"type": "color",
"role": "style",
"label": "Caret bottom",
"description": "Bottom color of the caret gradient.",
"default": "#8A5CF6"
}
]
}
+181
View File
@@ -0,0 +1,181 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Pendulum Swing - Demo</title>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
}
#demo {
position: relative;
width: 1920px;
height: 1080px;
display: grid;
place-items: center;
background: #f7f7f8;
font-family: Inter, system-ui, sans-serif;
}
.demo-frame {
display: flex;
flex-direction: column;
align-items: center;
gap: 40px;
/* ponytail: scale on non-GSAP wrapper — safe, GSAP only animates
--hf-swing-angle on the pendulum root below. */
transform: scale(1.4);
transform-origin: center center;
}
/* The hook the sign hangs from, drawn above the pivot for context. */
.swing-rig {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
}
.swing-hook {
width: 18px;
height: 18px;
border-radius: 50%;
background: #a1a1aa;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.18);
}
/* Pinned to the frame bottom, clear of the sign at its widest swing. */
.swing-label {
position: absolute;
bottom: 160px;
left: 50%;
transform: translateX(-50%);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #71717a;
}
.hf-catalog-pendulum-swing {
display: inline-block;
transform-origin: var(--hf-swing-origin, 50% 0%);
transform: rotate(
calc(var(--hf-swing-angle, 0) * var(--hf-swing-dir, 1) * var(--hf-swing-max, 34deg))
);
}
.hf-catalog-pendulum-swing__body {
display: inline-block;
padding: 22px 40px;
border-radius: 14px;
background: var(--hf-swing-bg, #18181b);
color: var(--hf-swing-fg, #fafafa);
font-family: Inter, system-ui, sans-serif;
font-weight: 800;
font-size: 54px;
letter-spacing: -0.02em;
white-space: nowrap;
}
</style>
</head>
<body>
<div
id="demo"
data-composition-id="pendulum-swing-demo"
data-start="0"
data-width="1920"
data-height="1080"
data-duration="5"
>
<div class="swing-label">Pendulum Swing</div>
<div class="demo-frame">
<div class="swing-rig">
<div class="swing-hook"></div>
<div
class="hf-catalog-pendulum-swing"
data-composition-variables='[
{ "id": "pivot", "type": "enum", "role": "layout", "label": "Pivot", "description": "Where the content hangs from. The swing rotates about this point, so the top edge stays pinned.", "default": "top-center", "options": [{ "value": "top-center", "label": "Top center" }, { "value": "top-left", "label": "Top left" }, { "value": "top-right", "label": "Top right" }] },
{ "id": "amplitude", "type": "enum", "role": "motion", "label": "Amplitude", "description": "How far the first swing reaches before it settles. Every later swing scales with it, so the pendulum still comes to rest upright.", "default": "standard", "options": [{ "value": "gentle", "label": "Gentle" }, { "value": "standard", "label": "Standard" }, { "value": "wide", "label": "Wide" }] },
{ "id": "direction", "type": "enum", "role": "motion", "label": "Direction", "description": "Which side the pendulum swings in from: right lifts the first swing clockwise, left mirrors it.", "default": "right", "options": [{ "value": "right", "label": "Right" }, { "value": "left", "label": "Left" }] },
{ "id": "tone", "type": "enum", "role": "style", "label": "Tone", "description": "Content colour: ink for light frames, paper for dark ones, accent rides --brand.", "default": "ink", "options": [{ "value": "ink", "label": "Ink" }, { "value": "paper", "label": "Paper" }, { "value": "accent", "label": "Accent" }] }
]'
>
<span class="hf-catalog-pendulum-swing__body">Hanging&nbsp;Sign</span>
</div>
</div>
</div>
<script>
(function () {
"use strict";
var vars =
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {};
// Unrecognised overrides return to their declared defaults.
function pick(table, value, fallback) {
return Object.prototype.hasOwnProperty.call(table, value) ? value : fallback;
}
var pivots = {
"top-center": "50% 0%",
"top-left": "0% 0%",
"top-right": "100% 0%",
};
var amplitudes = { gentle: "18deg", standard: "34deg", wide: "52deg" };
var directions = { right: 1, left: -1 };
// Each tone is a fill/text pair so the sign keeps AA contrast.
var tones = {
ink: { bg: "#18181b", fg: "#fafafa" },
paper: { bg: "#fafafa", fg: "#18181b" },
accent: { bg: "var(--brand, #34d399)", fg: "#0b0b0c" },
};
var origin = pivots[pick(pivots, vars.pivot, "top-center")];
var maxAngle = amplitudes[pick(amplitudes, vars.amplitude, "standard")];
var dir = directions[pick(directions, vars.direction, "right")];
var tone = tones[pick(tones, vars.tone, "ink")];
var roots = document.querySelectorAll(".hf-catalog-pendulum-swing");
for (var i = 0; i < roots.length; i += 1) {
var root = roots[i];
root.style.setProperty("--hf-swing-origin", origin);
root.style.setProperty("--hf-swing-max", maxAngle);
root.style.setProperty("--hf-swing-dir", String(dir));
root.style.setProperty("--hf-swing-bg", tone.bg);
root.style.setProperty("--hf-swing-fg", tone.fg);
}
})();
</script>
<script>
const tl = gsap.timeline({ paused: true });
const startTime = 0.4;
// A pendulum does not snap upright in a single overshoot; it swings
// through the rest point several times, each pass smaller, until
// friction stills it. This ease is that motion: a cosine (the swings)
// inside an exponential decay (the damping). It is normalised so the
// tween still lands exactly upright (angle 0) at the end, and rotation
// stays sub-pixel under the seek-by-frame capture engine.
const swingSettle = (p) => 1 - Math.exp(-2.3 * p) * Math.cos(3.5 * Math.PI * p);
tl.fromTo(
".hf-catalog-pendulum-swing",
{ "--hf-swing-angle": 1 },
{
"--hf-swing-angle": 0,
duration: 2.9,
ease: swingSettle,
},
startTime,
);
tl.set({}, {}, 5);
window.__timelines = window.__timelines || {};
window.__timelines["pendulum-swing-demo"] = tl;
</script>
</div>
</body>
</html>
+130
View File
@@ -0,0 +1,130 @@
<!--
Pendulum Swing - motion primitive for HyperFrames.
Paste the markup, CSS and script into a composition. Drive the timeline
integration from a paused GSAP timeline so renders remain deterministic.
Variables. The script reads each one, falls back to the declared default on
anything missing or unrecognised, and writes the result as a custom property
the CSS below consumes. The timeline recipe stays untouched by them: it keeps
easing one unitless swing value from 1 down to 0, and these decide where the
content hangs from, how far the first swing reaches, which way it enters and
what colour it is.
- pivot (top-center | top-left | top-right, default top-center): where the
content hangs from. The swing rotates about this point, so the pinned top
edge never drifts.
- amplitude (gentle | standard | wide, default standard): how far the first
swing reaches, 18deg, 34deg or 52deg. Every later swing scales with it, so
whatever the reach the pendulum still comes to rest upright.
- direction (right | left, default right): which side the pendulum swings in
from. right lifts the first swing clockwise, left mirrors it.
- tone (ink | paper | accent, default ink): content colour. ink is
near-black for light frames, paper near-white for dark ones, accent rides
--brand.
On every default the result is identical to the original: a near-black sign
hanging from its top centre, entering from the right through a 34deg swing.
Motion note. The swing is a damped oscillation about the pivot: the content
passes through upright several times, each pass smaller, until it settles.
It rides a single unitless custom property (--hf-swing-angle) that the recipe
eases 1 -> 0, so rotation interpolates sub-pixel and stays smooth under the
seek-by-frame capture engine; nothing here animates a layout or reflow
property.
-->
<div
class="hf-catalog-pendulum-swing"
data-composition-variables='[
{ "id": "pivot", "type": "enum", "role": "layout", "label": "Pivot", "description": "Where the content hangs from. The swing rotates about this point, so the top edge stays pinned.", "default": "top-center", "options": [{ "value": "top-center", "label": "Top center" }, { "value": "top-left", "label": "Top left" }, { "value": "top-right", "label": "Top right" }] },
{ "id": "amplitude", "type": "enum", "role": "motion", "label": "Amplitude", "description": "How far the first swing reaches before it settles. Every later swing scales with it, so the pendulum still comes to rest upright.", "default": "standard", "options": [{ "value": "gentle", "label": "Gentle" }, { "value": "standard", "label": "Standard" }, { "value": "wide", "label": "Wide" }] },
{ "id": "direction", "type": "enum", "role": "motion", "label": "Direction", "description": "Which side the pendulum swings in from: right lifts the first swing clockwise, left mirrors it.", "default": "right", "options": [{ "value": "right", "label": "Right" }, { "value": "left", "label": "Left" }] },
{ "id": "tone", "type": "enum", "role": "style", "label": "Tone", "description": "Content colour: ink for light frames, paper for dark ones, accent rides --brand.", "default": "ink", "options": [{ "value": "ink", "label": "Ink" }, { "value": "paper", "label": "Paper" }, { "value": "accent", "label": "Accent" }] }
]'
>
<span class="hf-catalog-pendulum-swing__body">Hanging&nbsp;Sign</span>
</div>
<style>
.hf-catalog-pendulum-swing {
display: inline-block;
/* The pendulum swings about the hanging pivot. The angle stays a unitless
factor so the timeline can ease it 1 -> 0 (a damped oscillation through
upright) while amplitude and direction, set once from the variables,
decide the reach and the side. */
transform-origin: var(--hf-swing-origin, 50% 0%);
transform: rotate(
calc(var(--hf-swing-angle, 0) * var(--hf-swing-dir, 1) * var(--hf-swing-max, 34deg))
);
}
.hf-catalog-pendulum-swing__body {
display: inline-block;
padding: 22px 40px;
border-radius: 14px;
/* Tone sets a background/foreground pair so the sign stays legible whatever
the frame: the fill is the tone colour, the text its paired contrast. */
background: var(--hf-swing-bg, #18181b);
color: var(--hf-swing-fg, #fafafa);
font-family: Inter, system-ui, sans-serif;
font-weight: 800;
font-size: 54px;
letter-spacing: -0.02em;
white-space: nowrap;
}
</style>
<script>
(function () {
"use strict";
var vars =
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {};
// Unrecognised overrides return to their declared defaults.
function pick(table, value, fallback) {
return Object.prototype.hasOwnProperty.call(table, value) ? value : fallback;
}
var pivots = {
"top-center": "50% 0%",
"top-left": "0% 0%",
"top-right": "100% 0%",
};
var amplitudes = { gentle: "18deg", standard: "34deg", wide: "52deg" };
var directions = { right: 1, left: -1 };
// Each tone is a fill/text pair so the sign keeps AA contrast on any frame.
var tones = {
ink: { bg: "#18181b", fg: "#fafafa" },
paper: { bg: "#fafafa", fg: "#18181b" },
accent: { bg: "var(--brand, #34d399)", fg: "#0b0b0c" },
};
var origin = pivots[pick(pivots, vars.pivot, "top-center")];
var maxAngle = amplitudes[pick(amplitudes, vars.amplitude, "standard")];
var dir = directions[pick(directions, vars.direction, "right")];
var tone = tones[pick(tones, vars.tone, "ink")];
var roots = document.querySelectorAll(".hf-catalog-pendulum-swing");
for (var i = 0; i < roots.length; i += 1) {
var root = roots[i];
root.style.setProperty("--hf-swing-origin", origin);
root.style.setProperty("--hf-swing-max", maxAngle);
root.style.setProperty("--hf-swing-dir", String(dir));
root.style.setProperty("--hf-swing-bg", tone.bg);
root.style.setProperty("--hf-swing-fg", tone.fg);
}
})();
</script>
<!--
Timeline integration. The swing settles like a real pendulum: it passes
through upright several times, each pass smaller, until it comes to rest. That
is a cosine (the swings) inside an exponential decay (the damping), normalised
so the tween still lands exactly upright at the end.
const swingSettle = (p) => 1 - Math.exp(-2.3 * p) * Math.cos(3.5 * Math.PI * p);
tl.fromTo('.hf-catalog-pendulum-swing', { '--hf-swing-angle': 1 }, { '--hf-swing-angle': 0, duration: 2.9, ease: swingSettle }, startTime);
-->
@@ -0,0 +1,71 @@
{
"$schema": "https://hyperframes.heygen.com/schema/registry-item.json",
"name": "pendulum-swing",
"type": "hyperframes:component",
"title": "Pendulum Swing",
"description": "Content hangs from a fixed pivot and swings into rest with gravity-damped oscillation, like a hanging sign settling on its hook.",
"tags": ["motion-primitive", "physics", "swing", "settle", "reveal"],
"files": [
{
"path": "pendulum-swing.html",
"target": "compositions/components/pendulum-swing.html",
"type": "hyperframes:snippet"
}
],
"variables": [
{
"id": "pivot",
"type": "enum",
"role": "layout",
"label": "Pivot",
"description": "Where the content hangs from. The swing rotates about this point, so the top edge stays pinned.",
"default": "top-center",
"options": [
{ "value": "top-center", "label": "Top center" },
{ "value": "top-left", "label": "Top left" },
{ "value": "top-right", "label": "Top right" }
]
},
{
"id": "amplitude",
"type": "enum",
"role": "motion",
"label": "Amplitude",
"description": "How far the first swing reaches before it settles. Every later swing scales with it, so the pendulum still comes to rest upright.",
"default": "standard",
"options": [
{ "value": "gentle", "label": "Gentle" },
{ "value": "standard", "label": "Standard" },
{ "value": "wide", "label": "Wide" }
]
},
{
"id": "direction",
"type": "enum",
"role": "motion",
"label": "Direction",
"description": "Which side the pendulum swings in from: right lifts the first swing clockwise, left mirrors it.",
"default": "right",
"options": [
{ "value": "right", "label": "Right" },
{ "value": "left", "label": "Left" }
]
},
{
"id": "tone",
"type": "enum",
"role": "style",
"label": "Tone",
"description": "Content colour: ink for light frames, paper for dark ones, accent rides --brand.",
"default": "ink",
"options": [
{ "value": "ink", "label": "Ink" },
{ "value": "paper", "label": "Paper" },
{ "value": "accent", "label": "Accent" }
]
}
],
"preview": {
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/components/pendulum-swing.png"
}
}
+8 -11
View File
@@ -72,17 +72,14 @@ export function snippetOwnsItsMotion(snippetHtml: string): boolean {
return live.includes("__timelines") && live.includes("gsap.timeline");
}
/**
* Components that register a timeline but still render a still frame when the
* preview is built from their snippet.
*
* Both were measured, not guessed: their previews moved before this change and
* were static after, while every other self-contained component kept moving.
* The cause is in the pieces themselves rather than in the rule, so they keep
* the preview they had. That leaves their panel inert, which is the state they
* were already in, rather than trading an inert panel for a frozen preview.
*/
export const SNIPPET_PREVIEW_RENDERS_STILL = new Set(["ascii-render-pass", "star-rating-fill"]);
/** Components whose authored stage is needed for a working preview. */
export const PREFER_AUTHORED_DEMO = new Set([
// Their snippets render still frames; keep the demos that supply their motion.
"ascii-render-pass",
"star-rating-fill",
// Dark text needs the demo's light canvas and its 1280 × 720 framing.
"caret-swap",
]);
/** The classes the snippet hangs its declaration on, which its script targets. */
function declaringClasses(snippetHtml: string): string[] {
+2 -6
View File
@@ -37,10 +37,7 @@ import {
import { isLocalAsset } from "./registry-hosted-assets.ts";
import { componentFiles } from "./catalog/component-files.ts";
import { runAsCommand } from "./entrypoint.ts";
import {
snippetOwnsItsMotion,
SNIPPET_PREVIEW_RENDERS_STILL,
} from "./catalog/component-variables.ts";
import { snippetOwnsItsMotion, PREFER_AUTHORED_DEMO } from "./catalog/component-variables.ts";
import {
clearPinnedVariableValues,
externalizeDataUris,
@@ -164,8 +161,7 @@ function snippetFileFor(item: CatalogItem): string | null {
function buildsFromSnippet(item: CatalogItem, snippetFile: string): boolean {
return (
snippetOwnsItsMotion(readFileSync(snippetFile, "utf-8")) &&
!SNIPPET_PREVIEW_RENDERS_STILL.has(item.name)
snippetOwnsItsMotion(readFileSync(snippetFile, "utf-8")) && !PREFER_AUTHORED_DEMO.has(item.name)
);
}