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

# Per Word Rise

> Words or characters rise into place in a controlled blur-to-sharp cascade, settle softly on landing, and hold still until the cut (optional fade or up exit).

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="per-word-rise 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/per-word-rise.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/per-word-rise.png");document.body.appendChild(p)});<\/script></body></html>`} />

## Install

<InstallCommand command="npx hyperframes add per-word-rise" />

That writes one file: `compositions/components/per-word-rise.html`.

## Paste it into your composition

Open `compositions/components/per-word-rise.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`   | `WORDS IN MOTION` | string                    | Text whose words or characters rise into place.                                                            |
| `split`  | `word`            | `word`, `char`            | Animate the text as words or individual characters.                                                        |
| `cues`   | \`\`              | string                    | Comma-separated seconds (from mount start) for each unit landing. Empty keeps the authored cascade rhythm. |
| `accent` | `green`           | `green`, `blue`, `violet` | Contract accent token used for the text color.                                                             |
| `exit`   | `none`            | `none`, `fade`, `up`      | Optional departure. Default none: the line 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="per-word-rise"
  data-composition-src="compositions/components/per-word-rise.html"
  data-variable-values='{"text":"WORDS IN MOTION","split":"word","cues":"","accent":"green","exit":"none"}'
></div>
```

## Source

<Accordion title={`per-word-rise.html`}>
  ```html theme={null}
  <!doctype html>
  <!--
    per-word-rise: HyperFrames video primitive (text effects / reveal)

    Words, or characters through the split variable, rise into place with a
    controlled cascade and a small blur-to-sharp settle. Each unit lands with
    a short overshoot-free settle tail (the final few pixels glide in over the
    closing frames instead of stopping dead), then the finished line holds
    truly still until the frame cuts (exit none) or departs with the optional
    exit variable.

    Variables:
      - text (string, default "WORDS IN MOTION"): displayed text.
      - split (word | char, default "word"): animated unit size.
      - cues (string, default ""): comma-separated seconds (from mount start)
        for each unit's landing. Empty keeps the authored cascade rhythm.
      - accent (green | blue | violet, default "green"): text color.
      - exit (none | fade | up, default "none"): optional departure.

    Envelope, fixed IN and OUT with elastic HOLD only:
      IN_BASE  = 1.45s, the full staggered rise and sharp settle (cues can
                 stretch the rise phase past IN_BASE when they land later)
      HOLD     = max(0, D - IN - OUT), truly still: no drift
      OUT_BASE = 0.50s staggered departure when exit is fade or up, 0s when
                 exit is none (the default: the line holds until the cut)
      If D is shorter than IN_BASE + OUT_BASE, IN and OUT compress together.
      The timeline is never time-scaled.

    Mount contract: the runtime clones only this template. #root fills the host
    box, establishes the container query basis, has no data-width or data-height,
    and registers one paused timeline under the literal per-word-rise key.
  -->
  <html
    lang="en"
    data-composition-id="per-word-rise"
    data-composition-duration="3.5"
    data-composition-variables='[
      { "id": "text", "type": "string", "role": "content", "label": "Text", "description": "Text whose words or characters rise into place.", "default": "WORDS IN MOTION" },
      { "id": "split", "type": "enum", "role": "content", "label": "Split", "description": "Animate the text as words or individual characters.", "default": "word", "options": [{ "value": "word", "label": "Word" }, { "value": "char", "label": "Character" }] },
      { "id": "cues", "type": "string", "role": "timing", "label": "Cues", "description": "Comma-separated seconds (from mount start) for each unit landing. Empty keeps the authored cascade rhythm.", "default": "" },
      { "id": "accent", "type": "enum", "role": "style", "label": "Accent", "description": "Contract accent token used for the text color.", "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 line 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>Per Word Rise</title>
    </head>
    <body>
      <template>
        <div id="root" data-composition-id="per-word-rise" data-duration="3.5" 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;
            }

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

            .pwr-stage {
              display: flex;
              width: 88cqw;
              max-width: 88cqw;
              align-items: baseline;
              justify-content: center;
              flex-wrap: wrap;
              column-gap: var(--space-1, 1.8cqw);
              row-gap: var(--space-2, 2.8cqh);
              color: var(--pwr-accent);
              font-size: var(--pwr-font-size);
              font-weight: 780;
              line-height: 1.08;
              letter-spacing: -0.035em;
              text-align: center;
            }

            .pwr-stage.pwr-char {
              column-gap: 0.045em;
              letter-spacing: 0;
            }

            .pwr-unit {
              display: inline-block;
              flex: 0 0 auto;
              filter: blur(var(--pwr-blur, 0cqh));
              will-change: transform, filter, opacity;
            }

            .pwr-space {
              width: 0.36em;
            }
          </style>

          <div
            id="per-word-rise-clip"
            class="pwr-clip clip"
            data-start="0"
            data-duration="3.5"
            data-track-index="0"
          >
            <div class="pwr-stage" role="img"></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(".pwr-stage");
              var vars =
                window.__hyperframes && window.__hyperframes.getVariables
                  ? window.__hyperframes.getVariables()
                  : {};

              var text = vars.text == null ? "WORDS IN MOTION" : String(vars.text);
              var split = vars.split === "char" ? "char" : "word";
              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;
              }, []);

              root.style.setProperty("--pwr-accent", accentColors[accent]);
              stage.classList.toggle("pwr-char", split === "char");
              stage.setAttribute("aria-label", text);

              var characterCount = Math.max(1, Array.from(text).length);
              var fittedCqw = Math.min(9.5, 126 / characterCount);
              root.style.setProperty(
                "--pwr-font-size",
                "min(" + fittedCqw.toFixed(3) + "cqw, 15cqh)",
              );

              var segments = split === "char" ? Array.from(text) : text.trim().split(/\s+/);
              if (segments.length === 0) segments = [""];

              var units = segments.map(function (segment, index) {
                var unit = document.createElement("span");
                unit.id = "per-word-rise-unit-" + index;
                unit.className = "pwr-unit";
                if (split === "char" && /\s/.test(segment)) {
                  unit.classList.add("pwr-space");
                  unit.textContent = "\u00a0";
                } else {
                  unit.textContent = segment;
                }
                unit.setAttribute("aria-hidden", "true");
                stage.appendChild(unit);
                return unit;
              });

              var IN_BASE = 1.45;
              var OUT_BASE = exit === "none" ? 0 : 0.5;
              var RISE_BASE = 0.74;
              var STAGGER_BASE = 0.62;
              var EXIT_STAGGER_BASE = 0.14;
              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 IN = IN_BASE * scale;
              var OUT = OUT_BASE * scale;
              var RISE = RISE_BASE * scale;
              var STAGGER = units.length > 1 ? STAGGER_BASE * scale : 0;
              var EXIT_STAGGER = units.length > 1 ? EXIT_STAGGER_BASE * scale : 0;
              var EXIT_MOVE = Math.max(0.001, OUT - EXIT_STAGGER);

              // Landing ease: overshoot-free decel with a settle tail. A plain
              // power3.out slams from fast to imperceptible in ~2 frames and the
              // words stop dead; this piecewise cubic keeps ~7% of the travel for
              // the last 22% of the duration and is C1-continuous at the seam,
              // so the word glides its final few pixels over the closing frames
              // (a settle) with monotonic velocity: no stall, no hop, no
              // overshoot. Pure function of progress, so it stays seek-safe.
              var LAND_SPLIT = 0.78; // progress where the settle tail begins
              var LAND_RESIDUAL = 0.07; // fraction of travel left for the tail
              var LAND_SEAM_SLOPE = ((1 - LAND_RESIDUAL) / LAND_SPLIT) * 0.5; // shared velocity at the seam
              function landEase(p) {
                if (p <= 0) return 0;
                if (p >= 1) return 1;
                if (p < LAND_SPLIT) {
                  var x = p / LAND_SPLIT;
                  var inv = 1 - x;
                  // Half cubic-out, half linear: keeps end velocity for the seam.
                  return (1 - LAND_RESIDUAL) * (0.5 * (1 - inv * inv * inv) + 0.5 * x);
                }
                var u = (p - LAND_SPLIT) / (1 - LAND_SPLIT);
                var b = LAND_SEAM_SLOPE * (1 - LAND_SPLIT);
                var c = 3 * LAND_RESIDUAL - 2 * b;
                var d = b - 2 * LAND_RESIDUAL;
                return 1 - LAND_RESIDUAL + b * u + c * u * u + d * u * u * u;
              }

              // Cues own the word landings when provided; the authored cascade
              // otherwise. Landings beyond the cue list extrapolate at the cue
              // list's own gap, and every landing clamps ahead of any exit.
              var riseEnd = IN;
              var landings;
              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))
                    : RISE * 0.4;
                var latestLanding = Math.max(RISE * 0.5, duration - OUT);
                landings = units.map(function (unit, index) {
                  var landing =
                    index < cues.length
                      ? cues[index]
                      : lastCue + (index - (cues.length - 1)) * cueGap;
                  return Math.min(latestLanding, Math.max(0.05, landing));
                });
                riseEnd = Math.min(duration - OUT, Math.max(IN, Math.max.apply(null, landings)));
              } else {
                // Authored cascade: same left-to-right wave the stagger drew,
                // expressed as explicit landings so every unit gets the settle.
                var step = units.length > 1 ? STAGGER / (units.length - 1) : 0;
                landings = units.map(function (unit, index) {
                  return index * step + RISE;
                });
              }
              var HOLD_START = riseEnd;
              var HOLD = Math.max(0, duration - (HOLD_START + OUT));
              var OUT_START = HOLD_START + HOLD;

              gsap.set(units, { opacity: 0, y: "8cqh", "--pwr-blur": "1.4cqh" });

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

              units.forEach(function (unit, index) {
                var landing = landings[index];
                var startAt = Math.max(0, landing - RISE);
                tl.to(
                  unit,
                  {
                    opacity: 1,
                    y: "0cqh",
                    "--pwr-blur": "0cqh",
                    duration: Math.max(0.08, landing - startAt),
                    ease: landEase,
                  },
                  startAt,
                );
              });

              // TRUE hold: the finished line is dead still until the exit or
              // the cut. The old 0.55cqh drift was sub-perceptual at 1080p.

              // OUT: optional departure; exit none holds until the frame cuts.
              if (exit === "up") {
                tl.to(
                  units,
                  {
                    opacity: 0,
                    y: "-4.5cqh",
                    "--pwr-blur": "0.7cqh",
                    duration: EXIT_MOVE,
                    stagger: { amount: EXIT_STAGGER, from: "start" },
                    ease: "power2.in",
                  },
                  OUT_START,
                );
              } else if (exit === "fade") {
                tl.to(
                  units,
                  {
                    opacity: 0,
                    duration: EXIT_MOVE,
                    stagger: { amount: EXIT_STAGGER, from: "start" },
                    ease: "power2.in",
                  },
                  OUT_START,
                );
              }

              tl.seek(0);
              window.__timelines = window.__timelines || {};
              window.__timelines["per-word-rise"] = tl;
            })();
          </script>
        </div>
      </template>
    </body>
  </html>
  ```
</Accordion>

Tagged `motion-primitive` `text-effects` `typography` `stagger` `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

- [Per-Word Crossfade](/catalog/components/per-word-crossfade.md)
- [Music videos and slideshows](/prompting/music-and-slideshows.md)
- [Word-Sweep Highlight](/catalog/blocks/mk-callout-highlight.md)
