> ## Documentation Index
> Fetch the complete documentation index at: /llms.txt
> Use this file to discover all available pages before exploring further.

# Variable Font Flex

> A word lands while its variable-font weight and width axes flex from hairline-condensed to black-wide with per-character stagger; font-size eases inversely so the letterforms gain mass while the box holds.

# Variable Font Flex

A word lands while its variable-font weight and width axes flex from hairline-condensed to black-wide with per-character stagger; font-size eases inversely so the letterforms gain mass while the box holds.

**Variable Font Flex**

```html variable-font-flex.html theme={null}
<!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>
```

Copy npx hyperframes add variable-font-flex to the clipboard

That writes one file: `compositions/components/variable-font-flex.html`.

## Paste it into your composition

Open `compositions/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. |

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 theme={null}
<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>
```

Tagged `motion-primitive` `experiment` `type` `variable-font` `deterministic`.

## Related topics

* [Browse the complete Catalog](/catalog.md)
* [Add assets and Catalog items in the browser editor](/studio/assets-and-blocks.md)
* [Put blocks together into one video](/concepts/compositions.md)


## Related topics

- [Embedded Override Mode](/sdk/guides/embedded-override-mode.md)
- [Variable Axis Type](/catalog/components/variable-axis-type.md)
- [Captions, assets and variables](/studio/assets-and-blocks.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.