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

# Text Match Cut

> One line of text becomes another mid-move: both ride a single 85 % easy-ease curve and hard-swap at the speed peak, as scale, tracking, or a same-direction slide.

# Text Match Cut

One line of text becomes another mid-move: both ride a single 85 % easy-ease curve and hard-swap at the speed peak, as scale, tracking, or a same-direction slide.

**Text Match Cut**

```html text-match-cut.html
<!doctype html>
<!--
  text-match-cut: HyperFrames video primitive (transitions / match-cut / connect)

  Concept: one line of text becomes another in the middle of a move. Both texts
  ride ONE value curve — the After Effects recipe is a single keyframe pair,
  split at the speed peak, with the second text continuing the first layer's
  motion. The outgoing text is removed and the incoming text appears at the same
  value, still moving, so the cut lands at peak velocity with no crossfade.

  Measured from the source tutorial at 60 fps: the keyframe pair is easy-eased
  with 85 % influence on both sides, cubic-bezier(.85, 0, .15, 1); the split is
  exactly half-way through the move; a cyan→blue gradient bar sweeps through
  the glyphs on its own 87 % curve and crosses the text centre at the cut.

  Variables:
    - text_a (string, default "Basic Interface Behavior"): outgoing line.
    - text_b (string, default "Thoughtful Experience Design"): incoming line.
    - mode (enum, default scale-down): which property carries the cut.
        scale-down   170 → 100 %   (the tutorial's move, Z pull)
        scale-up      60 → 100 %   (same curve, opposite sign, Z push)
        tracking-in  +0.22 → −0.05 em (the line contracts through the cut)
        slide-left / slide-right / slide-up / slide-down (exit and enter in
        the same direction, partial travel, the incoming line lands off-centre
        by the same distance the outgoing one started off-centre)
    - sweep (enum on | off, default on): the gradient bar through the glyphs.
    - background (enum navy | transparent, default navy): the tutorial's navy
      glow, or nothing so the host scene shows through.

  Envelope (fixed IN/OUT, elastic HOLD only, never timeScale):
    IN_BASE  = 2.00s  the whole move: text A rides the curve to the peak, text
               B continues it to rest
    HOLD     = elastic = max(0, D - (IN_BASE + OUT_BASE)); text B rests
    OUT_BASE = 0.30s  the shell fades cleanly
    If D is shorter than IN_BASE + OUT_BASE, IN and OUT compress together and
    HOLD becomes zero.

  Sync point: hard-cut at CUT_AT = 1.00s in the default duration, frame 30 at
  30 fps / frame 60 at 60 fps. Text A opacity becomes 0 and text B opacity
  becomes 1 at the same timeline instant.

  Mount contract: this is a template-wrapped sub-composition. The root has no
  data-width or data-height, fills its host box, uses container query units,
  and registers one paused timeline under the literal text-match-cut key.
-->
<html
  lang="en"
  data-composition-id="text-match-cut"
  data-composition-duration="3"
  data-composition-variables='[
    { "id": "text_a", "type": "string", "role": "content", "label": "Outgoing text", "description": "The line on screen before the cut.", "default": "Basic Interface Behavior" },
    { "id": "text_b", "type": "string", "role": "content", "label": "Incoming text", "description": "The line that continues the move after the cut.", "default": "Thoughtful Experience Design" },
    { "id": "mode", "type": "enum", "role": "style", "label": "Move", "description": "Which property carries the cut. Both texts ride the same curve.", "default": "scale-down", "options": [
      { "value": "scale-down", "label": "Scale down (tutorial)" }, { "value": "scale-up", "label": "Scale up" }, { "value": "tracking-in", "label": "Tracking in" },
      { "value": "slide-left", "label": "Slide left" }, { "value": "slide-right", "label": "Slide right" }, { "value": "slide-up", "label": "Slide up" }, { "value": "slide-down", "label": "Slide down" } ] },
    { "id": "sweep", "type": "enum", "role": "style", "label": "Gradient sweep", "description": "Cyan-to-blue bar sweeping through the glyphs around the cut.", "default": "on", "options": [{ "value": "on", "label": "On" }, { "value": "off", "label": "Off" }] },
    { "id": "background", "type": "enum", "role": "style", "label": "Background", "description": "The tutorial navy glow, or transparent so the host shows through.", "default": "navy", "options": [{ "value": "navy", "label": "Navy glow" }, { "value": "transparent", "label": "Transparent" }] }
  ]'
>
  <head>
    <meta charset="UTF-8" />
    <title>Text Match Cut</title>
  </head>
  <body>
    <template>
      <div id="root" data-composition-id="text-match-cut" data-duration="3" data-fps="30">
        <style>
          *,
          *::before,
          *::after {
            box-sizing: border-box;
          }

          #root {
            position: absolute;
            inset: 0;
            overflow: hidden;
            container-type: size;
            isolation: isolate;
            color: #ffffff;
            font-family:
              Inter,
              system-ui,
              -apple-system,
              "Segoe UI",
              sans-serif;
            pointer-events: none;
          }

          .tmx-clip,
          .tmx-shell {
            position: absolute;
            inset: 0;
            overflow: hidden;
          }

          .tmx-shell[data-bg="navy"] {
            background: radial-gradient(
              48cqw 58cqh at 50% 104%,
              #05055e 0%,
              #030346 25%,
              #02022e 50%,
              #000012 80%,
              #000006 100%
            );
          }

          /* One text per layer. The wrapper is centred and carries the move;
             the two spans stack so the gradient reads through the glyphs. */
          .tmx-text {
            position: absolute;
            left: 50%;
            top: 50%;
            white-space: nowrap;
            font-size: 8.2cqmin;
            font-weight: 450;
            letter-spacing: -0.052em;
            line-height: 1;
            transform-origin: 50% 50%;
            will-change: transform, filter;
          }

          .tmx-layer {
            position: absolute;
            left: 0;
            top: 0;
            white-space: nowrap;
            letter-spacing: inherit;
          }

          .tmx-grad {
            color: transparent;
            -webkit-background-clip: text;
            background-clip: text;
            background-repeat: no-repeat;
            background-image: linear-gradient(
              90deg,
              rgba(0, 197, 255, 0) 0%,
              rgba(0, 197, 255, 1) 16%,
              rgba(0, 180, 255, 1) 30%,
              rgba(7, 38, 249, 1) 58%,
              rgba(66, 71, 255, 1) 76%,
              rgba(93, 86, 255, 0) 100%
            );
          }

          .tmx-shell[data-sweep="off"] .tmx-grad {
            display: none;
          }
        </style>

        <div
          id="text-match-cut-clip"
          class="tmx-clip clip"
          data-start="0"
          data-duration="3"
          data-track-index="0"
        >
          <div class="tmx-shell" data-bg="navy" data-sweep="on">
            <div class="tmx-text tmx-text-a" aria-label="Outgoing text">
              <span class="tmx-layer tmx-white" data-layout-allow-occlusion></span
              ><span class="tmx-layer tmx-grad" aria-hidden="true"></span>
            </div>
            <div class="tmx-text tmx-text-b" aria-label="Incoming text">
              <span class="tmx-layer tmx-white" data-layout-allow-occlusion></span
              ><span class="tmx-layer tmx-grad" aria-hidden="true"></span>
            </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(".tmx-shell");
            var texts = [root.querySelector(".tmx-text-a"), root.querySelector(".tmx-text-b")];
            var vars =
              window.__hyperframes && window.__hyperframes.getVariables
                ? window.__hyperframes.getVariables()
                : {};

            var MODES = {
              "scale-down": { prop: "scale", v0: 1.7, v1: 1.0, sweep: 1 },
              "scale-up": { prop: "scale", v0: 0.6, v1: 1.0, sweep: 1 },
              "tracking-in": { prop: "tracking", v0: 0.22, v1: -0.052, sweep: 1 },
              "slide-left": { prop: "x", v0: 0.0885, v1: -0.0885, sweep: -1 },
              "slide-right": { prop: "x", v0: -0.0885, v1: 0.0885, sweep: 1 },
              "slide-up": { prop: "y", v0: 0.139, v1: -0.139, sweep: 1 },
              "slide-down": { prop: "y", v0: -0.139, v1: 0.139, sweep: 1 },
            };
            var mode = MODES[vars.mode] ? MODES[vars.mode] : MODES["scale-down"];
            var textA = vars.text_a == null ? "Basic Interface Behavior" : String(vars.text_a);
            var textB = vars.text_b == null ? "Thoughtful Experience Design" : String(vars.text_b);
            shell.dataset.bg = vars.background === "transparent" ? "transparent" : "navy";
            shell.dataset.sweep = vars.sweep === "off" ? "off" : "on";
            var copy = [textA, textB];
            texts.forEach(function (el, i) {
              el.querySelector(".tmx-white").textContent = copy[i];
              el.querySelector(".tmx-grad").textContent = copy[i];
            });

            // cubic-bezier(a, 0, 1 - a, 1), solved by bisection. 0.85 is the
            // measured AE easy-ease influence; 0.87 is the gradient precomp's.
            function bez(a, x) {
              var p1x = a,
                p2x = 1 - a,
                lo = 0,
                hi = 1,
                t,
                bx;
              for (var i = 0; i < 28; i++) {
                t = (lo + hi) / 2;
                bx = 3 * (1 - t) * (1 - t) * t * p1x + 3 * (1 - t) * t * t * p2x + t * t * t;
                if (bx < x) lo = t;
                else hi = t;
              }
              t = (lo + hi) / 2;
              return 3 * (1 - t) * t * t + t * t * t;
            }
            var E85 = function (x) {
              return bez(0.85, x);
            };
            var EBAR = function (x) {
              return bez(0.87, x);
            };

            var IN_BASE = 2.0;
            var OUT_BASE = 0.3;
            var duration = Math.max(0.001, parseFloat(root.dataset.duration || "3"));
            var envelopeBase = IN_BASE + OUT_BASE;
            var phaseScale = duration < envelopeBase ? duration / envelopeBase : 1;
            var IN = IN_BASE * phaseScale;
            var OUT = OUT_BASE * phaseScale;
            var CUT_AT = IN / 2;
            var HOLD = Math.max(0, duration - IN - OUT);
            var OUT_START = IN + HOLD;
            var FPS = parseFloat(root.dataset.fps || "30") || 30;

            // Geometry, measured at build time so the tweens are pure functions of time.
            var W = root.clientWidth || 1920;
            var H = root.clientHeight || 1080;
            var FS = parseFloat(getComputedStyle(texts[0]).fontSize) || H * 0.082;
            var natW = texts.map(function (el, i) {
              var w = el.querySelector(".tmx-white").getBoundingClientRect().width;
              return w > 0 ? w : copy[i].length * FS * 0.47;
            });
            var CX = W / 2;
            var D_GRAD = (2316 / 1920) * W; // gradient bar travel
            var XG_CUT = CX - (12 / 1920) * W; // bar centre at the cut
            var BAR = (720 / 1920) * W; // bar width (~620 px of it reads as colour)
            texts.forEach(function (el, i) {
              gsap.set(el, {
                width: natW[i],
                height: FS,
                xPercent: -50,
                yPercent: -50,
                transformOrigin: "50% 50%",
              });
            });

            // One value curve. p = E85(t / IN); value = v0 + (v1 - v0) * p; both texts read it.
            var clock = { t: 0 };
            function apply() {
              var tt = Math.min(1, Math.max(0, clock.t));
              var p = E85(tt);
              var val = mode.v0 + (mode.v1 - mode.v0) * p;
              var s = mode.prop === "scale" ? val : 1;
              var x = mode.prop === "x" ? val * W : 0;
              var y = mode.prop === "y" ? val * H : 0;
              var ls = mode.prop === "tracking" ? val : -0.052;
              var xg = XG_CUT + (EBAR(tt) - 0.5) * D_GRAD * mode.sweep;
              // motion smear stands in for AE motion blur: proportional to px per frame
              var dp =
                (E85(Math.min(1, tt + 0.002)) - E85(Math.max(0, tt - 0.002))) / 0.004 / IN / FPS;
              var v = 0;
              if (mode.prop === "x") v = Math.abs((mode.v1 - mode.v0) * W * dp);
              else if (mode.prop === "y") v = Math.abs((mode.v1 - mode.v0) * H * dp);
              else if (mode.prop === "scale")
                v = Math.abs(((mode.v1 - mode.v0) * dp * natW[tt < 0.5 ? 0 : 1]) / 2);
              else v = Math.abs((mode.v1 - mode.v0) * dp * FS * 12);
              var blur = Math.min(6, v * 0.07) * (H / 1080);
              texts.forEach(function (el, i) {
                var w = natW[i] + (ls + 0.052) * FS * copy[i].length;
                if (mode.prop === "tracking") gsap.set(el, { width: w });
                el.style.letterSpacing = ls + "em";
                gsap.set(el, { scale: s, x: x, y: y, filter: "blur(" + blur.toFixed(2) + "px)" });
                var bw = BAR / s;
                var g = el.querySelector(".tmx-grad");
                g.style.backgroundSize = bw + "px 100%";
                g.style.backgroundPositionX = w / 2 + (xg - CX - x) / s - bw / 2 + "px";
              });
            }

            var tl = gsap.timeline({ paused: true });

            tl.set(shell, { opacity: 0 }, 0);
            tl.set(texts[0], { autoAlpha: 1 }, 0);
            tl.set(texts[1], { autoAlpha: 0 }, 0);
            tl.to(shell, { opacity: 1, duration: Math.min(0.2, IN), ease: "power2.out" }, 0);
            tl.fromTo(
              clock,
              { t: 0 },
              { t: 1, duration: IN, ease: "none", onUpdate: apply, immediateRender: false },
              0,
            );

            // HARD CUT at the speed peak: text A leaves, text B arrives at the same value.
            tl.set(texts[0], { autoAlpha: 0 }, CUT_AT);
            tl.set(texts[1], { autoAlpha: 1 }, CUT_AT);

            tl.to(shell, { opacity: 0, duration: OUT, ease: "power2.in" }, OUT_START);

            apply();
            tl.seek(0);
            window.__timelines = window.__timelines || {};
            window.__timelines["text-match-cut"] = tl;
          })();
        </script>
      </div>
    </template>
  </body>
</html>
```

Copy npx hyperframes add text-match-cut to the clipboard

That writes one file: `compositions/components/text-match-cut.html`.

## Paste it into your composition

Open `compositions/components/text-match-cut.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_a` | `Basic Interface Behavior` | string | The line on screen before the cut. |
| `text_b` | `Thoughtful Experience Design` | string | The line that continues the move after the cut. |
| `mode` | `scale-down` | `scale-down`, `scale-up`, `tracking-in`, `slide-left`, `slide-right`, `slide-up`, `slide-down` | Which property carries the cut. Both texts ride the same curve. |
| `sweep` | `on` | `on`, `off` | Cyan-to-blue bar sweeping through the glyphs around the cut. |
| `background` | `navy` | `navy`, `transparent` | The tutorial navy glow, or transparent so the host shows through. |

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="text-match-cut"
  data-composition-src="compositions/components/text-match-cut.html"
  data-variable-values='{"text_a":"Basic Interface Behavior","text_b":"Thoughtful Experience Design","mode":"scale-down","sweep":"on","background":"navy"}'
></div>
```

{/* hf:generated-footer */}

Tagged `motion-primitive` `transition` `typography` `match-cut` `hard-cut` `continuity`.

## 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)
