Files
hyperframes/docs/catalog/components/icon-morph-beat.mdx
T
Miguel Ángel 9734578e60 feat(registry): bring back the video-primitive moves (#3169)
Restores the 208 catalog items reverted after their previews 404'd in
production, this time on the payload mechanism rather than the .html files
that caused the outage.

The generator no longer writes a preview document to docs/public. That writer,
and the machinery under it, existed only to produce files the docs host
discards, so it is gone rather than bypassed. Items now embed the composition
itself via a payload, which is what the previous change already does for the
items that were already in the catalog.

The variables explorer is parked, not restored: it drove its preview through
the same unpublished .html path, so it would have shown an empty frame. Items
that declare variables get the live player plus the static variables table, and
reconnecting the explorer to payloads is a follow-up.
2026-08-10 18:46:03 -04:00

325 lines
12 KiB
Plaintext

---
title: "Icon Morph Beat"
description: "An icon morphs between authored state silhouettes, shifts to an accent color, and marks completion with one restrained pulse."
---
import { InstallCommand } from "/snippets/install-command.jsx";
<iframe
className="w-full aspect-video rounded-xl border-0 bg-zinc-100 dark:bg-zinc-800"
title="icon-morph-beat preview"
loading="lazy"
srcDoc={`<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;height:100%;overflow:hidden;background:transparent}hyperframes-player{display:block;width:100%;height:100%}</style><script src="https://cdn.jsdelivr.net/npm/@hyperframes/player@0.7/dist/hyperframes-player.global.js"><\/script></head><body><script>fetch("/public/catalog/components/icon-morph-beat.json").then(function(r){return r.json()}).then(function(d){var p=document.createElement("hyperframes-player");p.setAttribute("srcdoc",d.html);p.setAttribute("controls","");p.setAttribute("autoplay","");p.setAttribute("loop","");p.setAttribute("muted","");p.setAttribute("poster","https://static.heygen.ai/hyperframes-oss/docs/images/catalog/components/icon-morph-beat.png");document.body.appendChild(p)});<\/script></body></html>`}
/>
## Install
<InstallCommand command="npx hyperframes add icon-morph-beat" />
That writes one file: `compositions/components/icon-morph-beat.html`.
## Paste it into your composition
Open `compositions/components/icon-morph-beat.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 |
| --- | --- | --- | --- |
| `pair` | `mic-check` | `mic-check`, `play-check`, `lock-unlock` | Before and after icon states used by the morph. |
| `accent` | `green` | `green`, `blue`, `violet` | Color applied as the morph completes. |
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="icon-morph-beat"
data-composition-src="compositions/components/icon-morph-beat.html"
data-variable-values='{"pair":"mic-check","accent":"green"}'
></div>
```
## Source
<Accordion title={`icon-morph-beat.html`}>
```html
<!doctype html>
<!--
icon-morph-beat: HyperFrames video primitive (product-demo / state-change)
Concept: one icon holds as a readable before state, morphs into its after
state while shifting to the selected accent, then marks completion with a
small pulse. The authored SVG paths in every pair have matching command
structure and point count so GSAP can tween the d attribute directly.
Variables:
- pair (mic-check | play-check | lock-unlock, default mic-check): the
before and after icon states.
- accent (green | blue | violet, default green): the completion color.
Envelope (fixed IN, elastic HOLD, no OUT):
IN_BASE = 1.20s, readable before-state hold, morph, and completion pulse
HOLD = elastic = max(0, D - IN_BASE), a restrained halo breath
OUT_BASE = 0s, the completed icon remains visible at the final frame
If D is shorter than IN_BASE, the complete state-change beat compresses
proportionally and HOLD becomes zero.
Mount contract: the runtime clones only this template. #root fills the
host box, establishes the container query basis, has no data-width or
data-height, and registers one paused timeline under the hardcoded
icon-morph-beat id.
-->
<html
lang="en"
data-composition-id="icon-morph-beat"
data-composition-duration="2.5"
data-composition-variables='[
{ "id": "pair", "type": "enum", "role": "content", "label": "Icon pair", "description": "Before and after icon states used by the morph.", "default": "mic-check", "options": [{ "value": "mic-check", "label": "Mic to check" }, { "value": "play-check", "label": "Play to check" }, { "value": "lock-unlock", "label": "Lock to unlock" }] },
{ "id": "accent", "type": "enum", "role": "style", "label": "Accent", "description": "Color applied as the morph completes.", "default": "green", "options": [{ "value": "green", "label": "Green" }, { "value": "blue", "label": "Blue" }, { "value": "violet", "label": "Violet" }] }
]'
>
<head>
<meta charset="UTF-8" />
<title>Icon Morph Beat</title>
</head>
<body>
<template>
<div id="root" data-composition-id="icon-morph-beat" data-duration="2.5" data-fps="30">
<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
#root {
position: absolute;
inset: 0;
overflow: hidden;
container-type: size;
isolation: isolate;
color: var(--fg, #f8fafc);
pointer-events: none;
}
.imb-clip {
position: absolute;
inset: 0;
display: grid;
place-items: center;
overflow: hidden;
}
.imb-stage {
position: relative;
display: grid;
width: 42cqmin;
aspect-ratio: 1;
place-items: center;
}
.imb-halo {
position: absolute;
inset: -12%;
border-radius: 50%;
background: radial-gradient(
circle,
color-mix(in srgb, var(--imb-accent) 38%, transparent) 0%,
color-mix(in srgb, var(--imb-accent) 12%, transparent) 46%,
transparent 72%
);
opacity: 0.14;
will-change: opacity, transform;
}
.imb-shell {
position: relative;
display: grid;
width: 100%;
aspect-ratio: 1;
place-items: center;
border: 0.28cqmin solid color-mix(in srgb, var(--border, #475569) 76%, transparent);
border-radius: 28%;
background: linear-gradient(
145deg,
color-mix(in srgb, var(--surface, #1e293b) 86%, white 7%),
var(--surface, #1e293b)
);
box-shadow:
0 3.2cqh 8cqh color-mix(in srgb, var(--bg, #05070d) 58%, transparent),
inset 0 0 0 0.12cqmin color-mix(in srgb, white 10%, transparent);
transform-origin: 50% 50%;
will-change: transform;
}
.imb-icon {
width: 58%;
height: 58%;
overflow: visible;
color: var(--fg, #f8fafc);
filter: drop-shadow(0 0.8cqh 1.8cqh rgba(2, 6, 23, 0.34));
}
.imb-shape {
fill: currentColor;
fill-rule: nonzero;
}
</style>
<div
id="icon-morph-beat-clip"
class="imb-clip clip"
data-start="0"
data-duration="2.5"
data-track-index="0"
>
<div class="imb-stage">
<div class="imb-halo" aria-hidden="true"></div>
<div class="imb-shell">
<svg class="imb-icon" viewBox="0 0 100 100" role="img" aria-label="Icon state change">
<path class="imb-shape" d="" />
</svg>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
(function () {
"use strict";
var root = document.getElementById("root");
var shell = root.querySelector(".imb-shell");
var halo = root.querySelector(".imb-halo");
var icon = root.querySelector(".imb-icon");
var shape = root.querySelector(".imb-shape");
var vars =
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {};
// Each from/to pair has the same SVG command sequence and point
// count. Keep the pair together when editing either silhouette.
var paths = {
"mic-check": {
from: "M 42 18 L 58 18 L 64 24 L 64 48 L 61 56 L 56 62 L 56 72 L 68 72 L 68 82 L 32 82 L 32 72 L 44 72 L 44 62 L 39 56 L 36 48 L 36 24 Z",
to: "M 16 50 L 20 46 L 24 42 L 34 52 L 44 62 L 52 54 L 60 46 L 68 38 L 76 30 L 80 34 L 84 38 L 74 48 L 64 58 L 54 68 L 44 78 L 34 68 Z",
},
"play-check": {
from: "M 28 18 L 36 23 L 44 28 L 52 33 L 60 38 L 68 43 L 76 48 L 80 50 L 76 52 L 68 57 L 60 62 L 52 67 L 44 72 L 36 77 L 28 82 L 28 50 Z",
to: "M 16 50 L 20 46 L 24 42 L 34 52 L 44 62 L 52 54 L 60 46 L 68 38 L 76 30 L 80 34 L 84 38 L 74 48 L 64 58 L 54 68 L 44 78 L 34 68 Z",
},
"lock-unlock": {
from: "M 22 42 L 78 42 L 78 84 L 22 84 Z M 30 44 L 30 31 C 30 18 39 12 50 12 C 61 12 70 18 70 31 L 70 44 L 60 44 L 60 31 C 60 25 56 22 50 22 C 44 22 40 25 40 31 L 40 44 Z",
to: "M 22 42 L 78 42 L 78 84 L 22 84 Z M 34 44 L 34 31 C 34 18 43 12 54 12 C 65 12 74 18 74 31 L 74 35 L 64 35 L 64 31 C 64 25 60 22 54 22 C 48 22 44 25 44 31 L 44 44 Z",
},
};
var accents = {
green: "#34d399",
blue: "#38bdf8",
violet: "#a78bfa",
};
var pair = Object.prototype.hasOwnProperty.call(paths, vars.pair)
? vars.pair
: "mic-check";
var accent = Object.prototype.hasOwnProperty.call(accents, vars.accent)
? vars.accent
: "green";
var selectedPaths = paths[pair];
var accentColor = accents[accent];
root.style.setProperty("--imb-accent", accentColor);
shape.setAttribute("d", selectedPaths.from);
icon.setAttribute("aria-label", pair.replace("-", " to "));
var IN_BASE = 1.2;
var MORPH_AT_BASE = 0.35;
var MORPH_BASE = 0.55;
var PULSE_UP_BASE = 0.12;
var PULSE_DOWN_BASE = 0.18;
var duration = Math.max(0.001, parseFloat(root.dataset.duration || "2.5"));
var scale = duration < IN_BASE ? duration / IN_BASE : 1;
var IN = IN_BASE * scale;
var MORPH_AT = MORPH_AT_BASE * scale;
var MORPH = MORPH_BASE * scale;
var PULSE_UP = PULSE_UP_BASE * scale;
var PULSE_DOWN = PULSE_DOWN_BASE * scale;
var COMPLETE_AT = MORPH_AT + MORPH;
var HOLD = Math.max(0, duration - IN);
gsap.set(icon, { color: "#f8fafc" });
gsap.set(shell, { scale: 1 });
gsap.set(halo, { opacity: 0.14, scale: 1 });
var tl = gsap.timeline({ paused: true });
tl.to(
shape,
{ attr: { d: selectedPaths.to }, duration: MORPH, ease: "power3.out" },
MORPH_AT,
);
tl.to(icon, { color: accentColor, duration: MORPH, ease: "power2.out" }, MORPH_AT);
tl.to(shell, { scale: 1.08, duration: PULSE_UP, ease: "power3.out" }, COMPLETE_AT);
tl.to(
shell,
{ scale: 1, duration: PULSE_DOWN, ease: "power2.out" },
COMPLETE_AT + PULSE_UP,
);
tl.to(
halo,
{ opacity: 0.62, scale: 1.08, duration: PULSE_UP, ease: "power3.out" },
COMPLETE_AT,
);
tl.to(
halo,
{ opacity: 0.14, scale: 1, duration: PULSE_DOWN, ease: "power2.out" },
COMPLETE_AT + PULSE_UP,
);
if (HOLD > 0.04) {
var driftHalf = Math.min(0.45, HOLD / 2);
tl.fromTo(
halo,
{ opacity: 0.14 },
{
opacity: 0.22,
duration: driftHalf,
ease: "sine.inOut",
yoyo: true,
repeat: 1,
},
IN,
);
}
tl.seek(0);
window.__timelines = window.__timelines || {};
window.__timelines["icon-morph-beat"] = tl;
})();
</script>
</div>
</template>
</body>
</html>
```
</Accordion>
{/* hf:generated-footer */}
Tagged `motion-primitive` `product-demo` `icon` `morph` `state-change` `completion`.
## Related topics
- [Browse the complete Catalog](/catalog)
- [Add assets and Catalog items in Studio](/studio/assets-and-blocks)
- [Build a richer composition](/go-further)