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

# Kinetic Type Swap

> A held sentence keeps its fixed text in place while one widest-word masked slot rolls through alternatives and settles on the final option.

# Kinetic Type Swap

A held sentence keeps its fixed text in place while one widest-word masked slot rolls through alternatives and settles on the final option.

**Kinetic Type Swap**

```html kinetic-type-swap.html
<!doctype html>
<!--
  kinetic-type-swap: HyperFrames video primitive (text effects / swap)

  A held sentence keeps its prefix and suffix fixed while one masked word
  slot rolls through comma-separated options. Every option remains in the
  same CSS grid cell, so the slot takes the widest option's intrinsic width
  before motion starts and the sentence never reflows during a swap.

  Variables:
    - prefix (string, default "Ship"): fixed text before the rolling slot.
    - options (string, default "faster,smarter,together"): comma-separated
      words shown in order.
    - suffix (string, default ""): fixed text after the rolling slot.
    - cues (string, default ""): comma-separated seconds (from mount start)
      for each word swap. Empty keeps the authored even spread.
    - accent (green | blue | violet, default "green"): rolling word color.
    - exit (none | fade | up, default "none"): optional departure.

  Envelope, fixed IN and OUT with elastic HOLD only:
    IN_BASE  = 2.25s, fade in followed by every vertical word swap (cues can
               stretch the swap phase past IN_BASE when they land later)
    HOLD     = max(0, D - IN - OUT), final word stays locked with a soft pulse
    OUT_BASE = 0.45s departure when exit is fade or up, 0s when exit is
               none (the default: the sentence holds until the cut)
    If D is shorter than IN_BASE + OUT_BASE, IN and OUT compress together.
    The timeline is never time-scaled.

  Determinism: options are parsed once before timeline construction. GSAP
  owns every visible change, uses finite repeats, and registers one paused
  timeline under the literal kinetic-type-swap key.

  Mount contract: the runtime clones only this template. #root fills the host
  box, establishes the container query basis, and has no data-width or
  data-height. Only word transforms change position. The held sentence never
  moves, and the grid-owned slot width never changes.
-->
<html
  lang="en"
  data-composition-id="kinetic-type-swap"
  data-composition-duration="4"
  data-composition-variables='[
    { "id": "prefix", "type": "string", "role": "content", "label": "Prefix", "description": "Fixed sentence text before the rolling word slot.", "default": "Ship" },
    { "id": "options", "type": "string", "role": "content", "label": "Options", "description": "Comma-separated words shown in the rolling slot.", "default": "faster,smarter,together" },
    { "id": "suffix", "type": "string", "role": "content", "label": "Suffix", "description": "Fixed sentence text after the rolling word slot.", "default": "" },
    { "id": "cues", "type": "string", "role": "timing", "label": "Cues", "description": "Comma-separated seconds (from mount start) for each word swap. Empty keeps the authored even spread.", "default": "" },
    { "id": "accent", "type": "enum", "role": "style", "label": "Accent", "description": "Color used by the rolling word slot.", "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 sentence 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>Kinetic Type Swap</title>
  </head>
  <body>
    <template>
      <div id="root" data-composition-id="kinetic-type-swap" data-duration="4" 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);
            font-family: var(--font-display, Inter, system-ui, sans-serif);
            pointer-events: none;
          }

          .kts-clip {
            position: absolute;
            inset: 0;
            display: grid;
            place-items: center;
            overflow: hidden;
            background: var(--bg, transparent);
          }

          .kts-stage {
            display: grid;
            place-items: center;
            width: 94cqw;
            height: 70cqh;
            opacity: 0;
          }

          .kts-sentence {
            display: flex;
            align-items: baseline;
            justify-content: center;
            /* Word gap must scale with the display type, never a host layout
               spacing token: --space-* is chrome-scale and jams the sentence
               ("Makemomentumvisible") when the type is auto-fitted large. */
            gap: 0.26em;
            max-width: 94cqw;
            color: var(--fg, #f8fafc);
            font-size: var(--kts-font-size);
            font-weight: 760;
            line-height: 1.08;
            letter-spacing: -0.045em;
            white-space: nowrap;
          }

          .kts-prefix,
          .kts-suffix {
            flex: 0 0 auto;
          }

          .kts-prefix:empty,
          .kts-suffix:empty {
            display: none;
          }

          /* Every word occupies one grid cell and contributes its intrinsic
             width. Transforms do not affect layout, so this masked slot stays
             as wide as the widest option for the entire composition. */
          .kts-slot {
            display: inline-grid;
            flex: 0 0 auto;
            overflow: hidden;
            color: var(--kts-accent);
            text-shadow: 0 0 2.8cqh color-mix(in srgb, var(--kts-accent) 28%, transparent);
            vertical-align: baseline;
          }

          .kts-word {
            grid-area: 1 / 1;
            width: max-content;
            justify-self: center;
            color: var(--kts-accent);
            will-change: transform, opacity;
          }
        </style>

        <div
          id="kinetic-type-swap-clip"
          class="kts-clip clip"
          data-start="0"
          data-duration="4"
          data-track-index="0"
        >
          <div class="kts-stage" role="img">
            <div class="kts-sentence">
              <span class="kts-prefix"></span>
              <span class="kts-slot" aria-hidden="true"></span>
              <span class="kts-suffix"></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 stage = root.querySelector(".kts-stage");
            var prefixElement = root.querySelector(".kts-prefix");
            var slotElement = root.querySelector(".kts-slot");
            var suffixElement = root.querySelector(".kts-suffix");
            var vars =
              window.__hyperframes && window.__hyperframes.getVariables
                ? window.__hyperframes.getVariables()
                : {};

            var defaultOptions = ["faster", "smarter", "together"];
            var prefix = vars.prefix == null ? "Ship" : String(vars.prefix).trim();
            var suffix = vars.suffix == null ? "" : String(vars.suffix).trim();
            var rawOptions = vars.options == null ? defaultOptions.join(",") : String(vars.options);
            var options = rawOptions
              .split(",")
              .map(function (option) {
                return option.trim();
              })
              .filter(function (option) {
                return option.length > 0;
              });
            if (options.length === 0) options = defaultOptions.slice();

            var accentColors = {
              green: "var(--brand, #71f5a7)",
              blue: "var(--accent, #61a8ff)",
              violet: "var(--accent-2, #c5a3ff)",
            };
            var accent = Object.prototype.hasOwnProperty.call(accentColors, vars.accent)
              ? vars.accent
              : "green";
            var exit = vars.exit === "fade" || vars.exit === "up" ? vars.exit : "none";
            var cuesValue = vars.cues == null ? "" : String(vars.cues);
            var cues = cuesValue
              .split(",")
              .reduce(function (result, token) {
                var trimmed = token.trim();
                if (trimmed === "") return result;
                var seconds = Number(trimmed);
                if (!Number.isFinite(seconds) || seconds < 0) return result;
                result.push(seconds);
                return result;
              }, [])
              .sort(function (a, b) {
                return a - b;
              });

            root.style.setProperty("--kts-accent", accentColors[accent]);
            prefixElement.textContent = prefix;
            suffixElement.textContent = suffix;

            var words = options.map(function (option, index) {
              var word = document.createElement("span");
              word.className = "kts-word";
              word.id = "kinetic-type-swap-word-" + index;
              word.textContent = option;
              slotElement.appendChild(word);
              return word;
            });

            var finalSentence = [prefix, options[options.length - 1], suffix]
              .filter(function (part) {
                return part.length > 0;
              })
              .join(" ");
            stage.setAttribute("aria-label", finalSentence);

            var widestOptionLength = options.reduce(function (widest, option) {
              return Math.max(widest, Array.from(option).length);
            }, 1);
            var sentenceLength =
              Array.from(prefix).length + widestOptionLength + Array.from(suffix).length + 2;
            var fittedCqw = Math.min(12, 122 / Math.max(1, sentenceLength));
            root.style.setProperty(
              "--kts-font-size",
              "min(" + fittedCqw.toFixed(3) + "cqw, 22cqh)",
            );

            var IN_BASE = 2.25;
            var OUT_BASE = exit === "none" ? 0 : 0.45;
            var ARRIVAL_BASE = 0.3;
            var SWAP_START_BASE = 0.48;
            var ROLL_BASE = 0.46;
            var SETTLE_TAIL_BASE = 0.18;
            var duration = Math.max(0.001, parseFloat(root.dataset.duration || "4"));
            var totalBase = Math.max(0.001, IN_BASE + OUT_BASE);
            var scale = duration < totalBase ? duration / totalBase : 1;
            var IN = IN_BASE * scale;
            var OUT = OUT_BASE * scale;
            var ARRIVAL = ARRIVAL_BASE * scale;
            var SWAP_START = SWAP_START_BASE * scale;
            var ROLL = ROLL_BASE * scale;
            var SETTLE_TAIL = SETTLE_TAIL_BASE * scale;

            // Swap schedule: cues own the swap moments when provided; the
            // authored even spread otherwise. Swaps beyond the cue list
            // extrapolate at the cue list's own gap, and every swap clamps
            // so its roll and settle finish before any exit.
            var swapCount = words.length - 1;
            var swapTimes = [];
            var rollDuration = ROLL;
            if (swapCount > 0) {
              if (cues.length > 0) {
                var lastCue = cues[cues.length - 1];
                var cueGap =
                  cues.length >= 2 ? Math.max(0.05, (lastCue - cues[0]) / (cues.length - 1)) : ROLL;
                var latestSwap = Math.max(0, duration - OUT - ROLL - SETTLE_TAIL);
                for (var cueIndex = 0; cueIndex < swapCount; cueIndex += 1) {
                  var cueAt =
                    cueIndex < cues.length
                      ? cues[cueIndex]
                      : lastCue + (cueIndex - (cues.length - 1)) * cueGap;
                  swapTimes.push(Math.min(latestSwap, Math.max(0, cueAt)));
                }
                var minGap = Infinity;
                for (var gapIndex = 1; gapIndex < swapTimes.length; gapIndex += 1) {
                  minGap = Math.min(minGap, swapTimes[gapIndex] - swapTimes[gapIndex - 1]);
                }
                if (Number.isFinite(minGap)) {
                  rollDuration = Math.min(ROLL, Math.max(0.04, minGap * 0.72));
                }
              } else {
                var lastSwapStart = Math.max(SWAP_START, IN - SETTLE_TAIL - ROLL);
                var swapStep = swapCount === 1 ? 0 : (lastSwapStart - SWAP_START) / (swapCount - 1);
                rollDuration =
                  swapCount <= 2 ? ROLL : Math.min(ROLL, Math.max(0.04, swapStep * 0.72));
                for (var index = 1; index <= swapCount; index += 1) {
                  swapTimes.push(
                    swapCount === 1 ? lastSwapStart : SWAP_START + (index - 1) * swapStep,
                  );
                }
              }
            }
            var swapsEnd =
              swapTimes.length > 0
                ? swapTimes[swapTimes.length - 1] + rollDuration + SETTLE_TAIL
                : IN;
            var HOLD_START = Math.min(Math.max(0, duration - OUT), Math.max(IN, swapsEnd));
            var HOLD = Math.max(0, duration - (HOLD_START + OUT));
            var OUT_START = HOLD_START + HOLD;

            gsap.set(stage, { opacity: 0 });
            gsap.set(words, { yPercent: 112, opacity: 0 });
            gsap.set(words[0], { yPercent: 0, opacity: 1 });

            var tl = gsap.timeline({ paused: true });
            tl.to(stage, { opacity: 1, duration: ARRIVAL, ease: "power2.out" }, 0);

            // Masked exchange: the outgoing word whips fully past the slot
            // mask (power4.in) before the incoming word crosses center. The
            // two phases overlap only for ~10% of the roll, while both words
            // are opposite-edge slivers, so every frame shows at most one
            // legible word at full ink and never a double exposure.
            swapTimes.forEach(function (swapAt, swapIndex) {
              var wordIndex = swapIndex + 1;
              var incomingEase = wordIndex === words.length - 1 ? "back.out(1.7)" : "power4.out";
              var outDuration = rollDuration * 0.55;
              var inDuration = rollDuration * 0.55;
              var inStart = swapAt + rollDuration - inDuration;

              tl.to(
                words[wordIndex - 1],
                { yPercent: -112, duration: outDuration, ease: "power4.in" },
                swapAt,
              );
              tl.set(words[wordIndex - 1], { opacity: 0 }, swapAt + outDuration);
              tl.set(words[wordIndex], { opacity: 1 }, inStart);
              tl.to(
                words[wordIndex],
                { yPercent: 0, duration: inDuration, ease: incomingEase },
                inStart,
              );
            });

            var finalWord = words[words.length - 1];
            var beat = parseFloat(getComputedStyle(root).getPropertyValue("--dur-beat")) || 0.5;
            var pulseHalf = Math.min(beat, HOLD / 2);
            if (pulseHalf > 0) {
              var maxRepeat = Math.floor(HOLD / pulseHalf) - 1;
              var pulseRepeat = maxRepeat % 2 === 1 ? maxRepeat : maxRepeat - 1;
              if (pulseRepeat >= 1) {
                tl.to(
                  finalWord,
                  {
                    opacity: 0.9,
                    duration: pulseHalf,
                    ease: "sine.inOut",
                    yoyo: true,
                    repeat: pulseRepeat,
                  },
                  HOLD_START,
                );
              }
            }

            // OUT: optional departure; exit none holds until the frame cuts.
            if (exit === "fade") {
              tl.to(stage, { opacity: 0, duration: OUT, ease: "power2.in" }, OUT_START);
            } else if (exit === "up") {
              tl.to(stage, { opacity: 0, y: "-3cqh", duration: OUT, ease: "power2.in" }, OUT_START);
            }

            tl.seek(0);
            window.__timelines = window.__timelines || {};
            window.__timelines["kinetic-type-swap"] = tl;
          })();
        </script>
      </div>
    </template>
  </body>
</html>
```

Copy npx hyperframes add kinetic-type-swap to the clipboard

That writes one file: `compositions/components/kinetic-type-swap.html`.

## Paste it into your composition

Open `compositions/components/kinetic-type-swap.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 |
| - | - | - | - |
| `prefix` | `Ship` | string | Fixed sentence text before the rolling word slot. |
| `options` | `faster,smarter,together` | string | Comma-separated words shown in the rolling slot. |
| `suffix` | \`\` | string | Fixed sentence text after the rolling word slot. |
| `cues` | \`\` | string | Comma-separated seconds (from mount start) for each word swap. Empty keeps the authored even spread. |
| `accent` | `green` | `green`, `blue`, `violet` | Color used by the rolling word slot. |
| `exit` | `none` | `none`, `fade`, `up` | Optional departure. Default none: the sentence 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
<div
  data-composition-id="kinetic-type-swap"
  data-composition-src="compositions/components/kinetic-type-swap.html"
  data-variable-values='{"prefix":"Ship","options":"faster,smarter,together","suffix":"","cues":"","accent":"green","exit":"none"}'
></div>
```

{/* hf:generated-footer */}

Tagged `motion-primitive` `text-effects` `kinetic-type` `word-swap` `reveal`.

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