Install
That writes one file:compositions/components/variable-font-flex.html.
Paste it into your composition
Opencompositions/components/variable-font-flex.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 | FLEX | string | The word whose variable-font axes flex. |
axis | both | weight, width, both | Which variable-font axes animate: weight, width, or both. |
stagger | 0.06 | number | Seconds between per-character flex starts, clamped 0 to 0.25. |
accent | green | green, blue, violet | Word color: green rides —brand, blue rides —accent, violet rides —accent-2. |
exit | none | none, fade, up | Optional departure. Default none: the carved word holds until the frame cuts. |
data-variable-values on the element that mounts it. These are the
defaults, so this behaves exactly like the preview above until you change one:
<div
data-composition-id="variable-font-flex"
data-composition-src="compositions/components/variable-font-flex.html"
data-variable-values='{"text":"FLEX","axis":"both","stagger":0.06,"accent":"green","exit":"none"}'
></div>
Source
variable-font-flex.html
variable-font-flex.html
<!doctype html>
<!--
variable-font-flex: HyperFrames video primitive (type / Wave M experiment M5)
A word lands while its variable-font axes flex: each character rises from
hairline-condensed to black-wide (weight 120 to 840, width 40 to 128) with
a per-character stagger and a small carve-in overshoot, then settles. The
word's font-size eases INVERSELY to the mean axis progress (optical
compensation), so the letterforms gain mass while the word's box barely
grows: ink swells, layout holds.
Font: Roboto Flex Variable, pinned via the Fontsource CDN build
(@fontsource-variable/roboto-flex@5.2.8, latin "standard" subset:
wght 100..1000 and wdth 25%..151% in one woff2). Inter var was considered
per the spec but Inter carries NO wdth axis, and this unit's width mode
needs a real one; Roboto Flex has both. The @font-face below pins the
exact file. The pinned font IS the mechanic, so this unit deliberately
does not ride --font-display: a theme font without matching axes would
kill the effect. The landing weight (840) is the animated mechanic, not
static register styling.
Variables (declared in data-composition-variables below):
- text (string, default "FLEX"): the word.
- axis ("weight" | "width" | "both", default "both"): which axes flex.
weight holds wdth at 100; width holds wght at 620.
- stagger (number seconds, default 0.06, clamped 0 to 0.25): per-char
offset between axis flex starts.
- accent ("green" | "blue" | "violet", default "green"): green rides
--brand, blue rides --accent, violet rides --accent-2.
- exit ("none" | "fade" | "up", default "none").
Envelope, fixed IN and OUT with elastic HOLD only (never timeScale):
IN_BASE = 0.12 lead + (chars - 1) * stagger + 1.15 flex + 0.15 margin
HOLD = max(0, D - IN - OUT), truly still
OUT_BASE = 0.45s only when exit != none
If D < IN_BASE + OUT_BASE the whole envelope compresses together.
Determinism and seek-safety: NO tween ever touches the
font-variation-settings string. One inert anchor tween spans [0, D]; on
every update a painter recomputes each character's axis numbers as a pure
function of tl.time() (a number proxy per char) and composes the
'"wght" W, "wdth" D' string from those numbers, plus the word-level
compensated font-size. Writes are idempotent; eventful seeks
(suppressEvents=false) land identical frames in any order and direction.
Mount contract: the runtime clones only this template. #root fills the
host box (no data-width/data-height, container-type: size, cqmin units),
is styled via #root only, and registers one paused timeline under the
LITERAL "variable-font-flex" key.
-->
<html
lang="en"
data-composition-id="variable-font-flex"
data-composition-duration="3.5"
data-composition-variables='[
{ "id": "text", "type": "string", "role": "content", "label": "Text", "description": "The word whose variable-font axes flex.", "default": "FLEX" },
{ "id": "axis", "type": "enum", "role": "style", "label": "Axis", "description": "Which variable-font axes animate: weight, width, or both.", "default": "both", "options": [{ "value": "weight", "label": "Weight" }, { "value": "width", "label": "Width" }, { "value": "both", "label": "Both" }] },
{ "id": "stagger", "type": "number", "role": "timing", "label": "Stagger", "description": "Seconds between per-character flex starts, clamped 0 to 0.25.", "default": 0.06 },
{ "id": "accent", "type": "enum", "role": "style", "label": "Accent", "description": "Word color: green rides --brand, blue rides --accent, violet rides --accent-2.", "default": "green", "options": [{ "value": "green", "label": "Green" }, { "value": "blue", "label": "Blue" }, { "value": "violet", "label": "Violet" }] },
{ "id": "exit", "type": "enum", "role": "timing", "label": "Exit", "description": "Optional departure. Default none: the carved word holds until the frame cuts.", "default": "none", "options": [{ "value": "none", "label": "None" }, { "value": "fade", "label": "Fade" }, { "value": "up", "label": "Up" }] }
]'
>
<head>
<meta charset="UTF-8" />
<title>Variable Font Flex</title>
</head>
<body>
<template>
<div id="root" data-composition-id="variable-font-flex" data-duration="3.5" data-fps="30">
<style>
/* Pinned variable font: Fontsource build of Roboto Flex, latin
standard subset, wght 100..1000 + wdth 25%..151% in one file.
@fontsource-variable/roboto-flex@5.2.8 on jsdelivr. */
@font-face {
font-family: "Roboto Flex Variable";
font-style: normal;
font-display: block;
font-weight: 100 1000;
font-stretch: 25% 151%;
src: url(https://cdn.jsdelivr.net/npm/@fontsource-variable/roboto-flex@5.2.8/files/roboto-flex-latin-standard-normal.woff2)
format("woff2-variations");
}
*,
*::before,
*::after {
box-sizing: border-box;
}
#root {
position: absolute;
inset: 0;
overflow: hidden;
container-type: size;
isolation: isolate;
color: var(--fg, #f8fafc);
font-family: "Roboto Flex Variable", system-ui, sans-serif;
pointer-events: none;
}
.vff-clip {
position: absolute;
inset: 0;
display: grid;
place-items: center;
overflow: hidden;
background: var(--bg, transparent);
}
.vff-stage {
display: block;
max-width: 94cqw;
will-change: transform, opacity;
}
.vff-word {
display: flex;
align-items: baseline;
justify-content: center;
color: var(--vff-accent);
line-height: 1;
white-space: pre;
}
.vff-char {
position: relative;
display: inline-block;
white-space: pre;
/* No will-change and no transform on chars: per-char compositor
layer promotion makes glyph antialiasing depend on seek
history (paint-level nondeterminism); the rise rides
relative top instead. */
}
</style>
<div
id="variable-font-flex-clip"
class="vff-clip clip"
data-start="0"
data-duration="3.5"
data-track-index="0"
>
<div class="vff-stage" role="img">
<div class="vff-word" aria-hidden="true"></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 stage = root.querySelector(".vff-stage");
var word = root.querySelector(".vff-word");
var vars =
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {};
var text = vars.text == null || vars.text === "" ? "FLEX" : String(vars.text);
var axis = vars.axis === "weight" || vars.axis === "width" ? vars.axis : "both";
var staggerRaw = parseFloat(vars.stagger);
var stagger = isFinite(staggerRaw) ? Math.min(0.25, Math.max(0, staggerRaw)) : 0.06;
// Each enum choice routes to a DIFFERENT contract token so the
// variable stays meaningful under a theme.
var accentColors = {
green: "var(--brand, #52525b)",
blue: "var(--accent, #52525b)",
violet: "var(--accent-2, #52525b)",
};
var accent = Object.prototype.hasOwnProperty.call(accentColors, vars.accent)
? vars.accent
: "green";
var exit = vars.exit === "fade" || vars.exit === "up" ? vars.exit : "none";
root.style.setProperty("--vff-accent", accentColors[accent]);
stage.setAttribute("aria-label", text);
// Axis endpoints. Non-animated axes hold a fixed value so the
// composed string is always complete and orderable.
var WGHT_FROM = axis === "width" ? 620 : 120;
var WGHT_TO = axis === "width" ? 620 : 840;
var WDTH_FROM = axis === "weight" ? 100 : 40;
var WDTH_TO = axis === "weight" ? 100 : 128;
// Optical compensation strength per mode: how much of the mass
// gain the font-size gives back so the box barely grows.
var COMP = axis === "both" ? 0.16 : axis === "width" ? 0.12 : 0.08;
// Build one span per character; spaces stay in flow (white-space:
// pre) but never animate.
var characters = Array.from(text);
var hostDoc = root.ownerDocument;
var chars = [];
var fragment = hostDoc.createDocumentFragment();
for (var i = 0; i < characters.length; i += 1) {
var span = hostDoc.createElement("span");
span.className = "vff-char";
span.textContent = characters[i];
fragment.appendChild(span);
if (!/\s/.test(characters[i])) chars.push({ el: span, index: i });
}
word.appendChild(fragment);
// Fit for the FINAL (wide, black) state; compensation only ever
// shrinks below this. cqw base capped by cqh so short words on
// wide hosts stay inside the frame.
var fitted = Math.min(21, 118 / Math.max(3, characters.length));
var BASE_SIZE = "min(" + fitted.toFixed(3) + "cqw, 30cqh)";
root.style.setProperty("--vff-size", BASE_SIZE);
word.style.fontSize = "var(--vff-size)";
// Envelope: fixed IN/OUT, elastic HOLD, never time-scaled.
var LEAD = 0.12;
var FLEX = 1.15;
var MARGIN = 0.15;
var IN_BASE = LEAD + Math.max(0, chars.length - 1) * stagger + FLEX + MARGIN;
var OUT_BASE = exit === "none" ? 0 : 0.45;
var duration = Math.max(0.001, parseFloat(root.dataset.duration || "3.5"));
var totalBase = Math.max(0.001, IN_BASE + OUT_BASE);
var scale = duration < totalBase ? duration / totalBase : 1;
var lead = LEAD * scale;
var flex = FLEX * scale;
var staggerEff = stagger * scale;
var IN = IN_BASE * scale;
var OUT = OUT_BASE * scale;
var HOLD = Math.max(0, duration - (IN + OUT));
var OUT_START = IN + HOLD;
function clamp01(v) {
return v < 0 ? 0 : v > 1 ? 1 : v;
}
// Carve ease: overshoots ~6% past the target, then settles. The
// painter clamps composed axis values to the font's real ranges.
function carve(u) {
var c1 = 0.9;
var c3 = c1 + 1;
var x = u - 1;
return 1 + c3 * x * x * x + c1 * x * x;
}
function clampAxis(v, lo, hi) {
return v < lo ? lo : v > hi ? hi : v;
}
// Pure per-update painter: every character's axis NUMBERS are a
// function of tl.time() alone; the font-variation-settings
// string is composed from those numbers (the number proxy; the
// string itself is never tweened). The word-level font-size
// eases inversely to the mean settled progress: optical
// compensation, mass grows while the box holds.
function applyTime(timeSeconds) {
var settledSum = 0;
for (var c = 0; c < chars.length; c += 1) {
var start = lead + c * staggerEff;
var u = clamp01(flex > 0 ? (timeSeconds - start) / flex : 1);
var e = u <= 0 ? 0 : u >= 1 ? 1 : carve(u);
var settled = e > 1 ? 1 : e;
settledSum += settled;
var wght = clampAxis(WGHT_FROM + (WGHT_TO - WGHT_FROM) * e, 100, 1000);
var wdth = clampAxis(WDTH_FROM + (WDTH_TO - WDTH_FROM) * e, 25, 151);
var el = chars[c].el;
el.style.fontVariationSettings =
'"wght" ' + wght.toFixed(1) + ', "wdth" ' + wdth.toFixed(2);
var oIn = clamp01(flex > 0 ? (timeSeconds - start) / (flex * 0.22) : 1);
el.style.opacity = (oIn * oIn * (3 - 2 * oIn)).toFixed(4);
el.style.top = ((1 - settled) * 0.14).toFixed(4) + "em";
}
var mean = chars.length > 0 ? settledSum / chars.length : 1;
word.style.fontSize = "calc(var(--vff-size) * " + (1 - COMP * mean).toFixed(4) + ")";
}
gsap.set(stage, { opacity: 1, y: "0cqh" });
var tl = gsap.timeline({
paused: true,
onUpdate: function () {
applyTime(tl.time());
},
});
// Anchor tween: an inert plain-object tween spanning [0, D] so
// onUpdate fires for every eventful seek and holds never clamp.
tl.to({ p: 0 }, { p: 1, duration: duration, ease: "none" }, 0);
// HOLD: truly still; repeated repaints write identical values.
// OUT: optional departure; exit none holds until the frame cuts.
if (exit === "up") {
tl.to(stage, { y: "-4cqh", duration: OUT, ease: "power2.in" }, OUT_START);
tl.to(stage, { opacity: 0, duration: OUT, ease: "power2.in" }, OUT_START);
} else if (exit === "fade") {
tl.to(stage, { opacity: 0, duration: OUT, ease: "power2.in" }, OUT_START);
}
tl.seek(0);
applyTime(0);
window.__timelines = window.__timelines || {};
window.__timelines["variable-font-flex"] = tl;
})();
</script>
</div>
</template>
</body>
</html>
motion-primitive experiment type variable-font deterministic.