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

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

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>;
};

<iframe className="w-full aspect-video rounded-xl border-0 bg-zinc-100 dark:bg-zinc-800" title="kinetic-type-swap preview" loading="lazy" srcDoc={`<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;height:100%;overflow:hidden;background:transparent}hyperframes-player{display:block;width:100%;height:100%}</style><script src="https://cdn.jsdelivr.net/npm/@hyperframes/player@0.7/dist/hyperframes-player.global.js"><\/script></head><body><script>fetch("/public/catalog/components/kinetic-type-swap.json").then(function(r){return r.json()}).then(function(d){var p=document.createElement("hyperframes-player");p.setAttribute("srcdoc",d.html);p.setAttribute("controls","");p.setAttribute("autoplay","");p.setAttribute("loop","");p.setAttribute("muted","");p.setAttribute("poster","https://static.heygen.ai/hyperframes-oss/docs/images/catalog/components/kinetic-type-swap.png");document.body.appendChild(p)});<\/script></body></html>`} />

## Install

<InstallCommand command="npx hyperframes add kinetic-type-swap" />

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

## Source

<Accordion title={`kinetic-type-swap.html`}>
  ```html theme={null}
  <!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>
  ```
</Accordion>

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

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

- [Kinetic Slam](/catalog/components/caption-kinetic-slam.md)
- [Icon Swap](/catalog/components/icon-swap.md)
- [Morph Swap](/catalog/components/morph-swap.md)
