> ## Documentation Index
> Fetch the complete documentation index at: https://hyperframes.heygen.com/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.

export const InstallCommand = ({command}) => {
  const [copied, setCopied] = React.useState(false);
  const copy = async () => {
    try {
      if (navigator.clipboard && window.isSecureContext) {
        await navigator.clipboard.writeText(command);
      } else {
        const previous = document.activeElement;
        const scratch = document.createElement("textarea");
        scratch.value = command;
        scratch.setAttribute("readonly", "");
        scratch.style.position = "fixed";
        scratch.style.opacity = "0";
        document.body.appendChild(scratch);
        scratch.select();
        document.execCommand("copy");
        document.body.removeChild(scratch);
        previous?.focus?.();
      }
      setCopied(true);
      setTimeout(() => setCopied(false), 2000);
    } catch {}
  };
  return <div className="hf-install-command not-prose my-4 flex items-stretch overflow-hidden rounded-xl border border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900">
      <code className="flex-1 overflow-x-auto whitespace-nowrap border-r border-zinc-200 px-4 py-3 font-mono text-sm text-zinc-800 dark:border-zinc-800 dark:text-zinc-100">
        {command}
      </code>
      <button type="button" onClick={copy} data-copied={copied ? "true" : "false"} aria-label={`Copy ${command} to the clipboard`} className="hf-install-copy">
        <svg className="hf-install-copy-clipboard" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <path d="M14.25 5.25H7.25C6.14543 5.25 5.25 6.14543 5.25 7.25V14.25C5.25 15.3546 6.14543 16.25 7.25 16.25H14.25C15.3546 16.25 16.25 15.3546 16.25 14.25V7.25C16.25 6.14543 15.3546 5.25 14.25 5.25Z" />
          <path d="M2.80103 11.998L1.77203 5.07397C1.61003 3.98097 2.36403 2.96397 3.45603 2.80197L10.38 1.77297C11.313 1.63397 12.19 2.16297 12.528 3.00097" />
        </svg>
        <svg className="hf-install-copy-check" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <path d="M2.75 9.5L6.5 13.25L15.25 4.5" />
        </svg>
      </button>
      <span className="hf-install-copy-status" role="status" aria-live="polite">
        {copied ? "Copied" : ""}
      </span>
    </div>;
};

<video className="w-full aspect-video rounded-xl object-cover bg-zinc-100 dark:bg-zinc-800" src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/components/variable-font-flex.mp4" poster="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/components/variable-font-flex.png" autoPlay muted loop playsInline />

## Install

<InstallCommand command="npx hyperframes add variable-font-flex" />

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>
```

## Source

<Accordion title={`variable-font-flex.html`}>
  ```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>
  ```
</Accordion>

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

## Related topics

* [Browse the complete Catalog](/catalog)
* [Add assets and Catalog items in Studio](/studio/assets-and-blocks)
* [Build a richer composition](/go-further)


## Related topics

- [Embedded Override Mode](/sdk/guides/embedded-override-mode.md)
- [Variable Axis Type](/catalog/components/variable-axis-type.md)
- [3D UI Reveal](/catalog/blocks/ui-3d-reveal.md)
