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

# Cursor Glyph Trail

> An actor travels an authored path depositing small dithered glyphs at its past positions, each popping in and decaying in place, stamp rate scaling with the actor's velocity.

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

## Install

<InstallCommand command="npx hyperframes add cursor-glyph-trail" />

That writes one file: `compositions/components/cursor-glyph-trail.html`.

## Paste it into your composition

Open `compositions/components/cursor-glyph-trail.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                                                                              |
| --------- | -------- | ------------------------- | ----------------------------------------------------------------------------------------- |
| `glyphs`  | `░▒▓+·×` | string                    | Stamp charset; one glyph per stamp is chosen deterministically.                           |
| `density` | `med`    | `low`, `med`, `high`      | Arc-length spacing between stamps along the path.                                         |
| `path`    | `sweep`  | `sweep`, `arc`, `zigzag`  | Authored travel path preset.                                                              |
| `fade`    | `0.8`    | number                    | Per-stamp decay window in seconds, clamped 0.3 to 1.5.                                    |
| `accent`  | `green`  | `green`, `blue`, `violet` | Trail and actor color: green rides --brand, blue rides --accent, violet rides --accent-2. |
| `exit`    | `none`   | `none`, `fade`, `up`      | Optional departure. Default none: the settled actor 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="cursor-glyph-trail"
  data-composition-src="compositions/components/cursor-glyph-trail.html"
  data-variable-values='{"glyphs":"░▒▓+·×","density":"med","path":"sweep","fade":0.8,"accent":"green","exit":"none"}'
></div>
```

## Source

<Accordion title={`cursor-glyph-trail.html`}>
  ```html theme={null}
  <!doctype html>
  <!--
    cursor-glyph-trail: HyperFrames video primitive (product demo / trails)

    An actor (default: a cursor dot) travels an authored path and deposits
    small dithered glyphs at its past positions. Each glyph pops in where the
    actor passed and decays in place; stamps land at fixed arc-length spacing,
    so the per-second stamp rate scales with the actor's velocity. The jh3yy
    stamp-and-decay register: the trail is the residue of the motion, and it
    has fully dissolved before the hold.

    The actor is a SLOT. Callers supply content by placing an inert template
    anywhere in the HOST page (templates never render, and the runtime wipes
    the host clip's own children on mount, so the slot lives at document
    level):

      <template data-slot="cursor-glyph-trail-actor"> ... </template>

    With no slot, the primitive renders a token cursor dot (accent core,
    hairline ring).

    Variables (declared in data-composition-variables below):
      - glyphs (string): the stamp charset; one glyph is chosen per stamp by
        a fixed-seed LCG. Default is a dithered block-and-punctuation set.
      - density ("low" | "med" | "high", default "med"): arc-length spacing
        between stamps; higher density means tighter spacing.
      - path ("sweep" | "arc" | "zigzag", default "sweep"): the authored
        travel path preset.
      - fade (number seconds, default 0.8, clamped 0.3 to 1.5): the decay
        window of each stamp; per-stamp decay varies 0.75x to 1.25x of it.
      - accent ("green" | "blue" | "violet", default "green"): green rides
        --brand, blue rides --accent, violet rides --accent-2.
      - exit ("none" | "fade" | "up", default "none"): frame roots own
        transitions; the default holds the final frame to the last pixel.

    Envelope, fixed IN and OUT with elastic HOLD only (never timeScale):
      IN_BASE  = TRAVEL (2.4s) + pop (0.07s) + fade * 1.25 + 0.05s margin;
                 the actor arrives at TRAVEL and every stamp's decay tween
                 ends inside IN, so the HOLD is truly still
      HOLD     = max(0, D - IN - OUT)
      OUT_BASE = 0.45s  only when exit != none
      If D < IN_BASE + OUT_BASE, the whole envelope compresses together.

    Determinism: the stamp table (positions, times, glyph choices, jitter,
    sizes, peaks, decay multipliers) is computed exactly once during
    synchronous mount from fixed LCG seed 0x67117a11. On every timeline
    update, each stamp's opacity is recomputed as a pure function
    f(tl.time() - t_i) of its table row, and the actor's position as a pure
    function of tl.time(); stamps deliberately carry NO tweens (two
    sequential tweens on one property render in direction-dependent order
    under GSAP seeks, which breaks byte-identical frames). No Math.random
    at runtime, no wall clock, no incremental state, no
    requestAnimationFrame. Eventful seeks (suppressEvents=false, the
    engine's render path) land identical frames in any order and either
    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 "cursor-glyph-trail" key.
  -->
  <html
    lang="en"
    data-composition-id="cursor-glyph-trail"
    data-composition-duration="4"
    data-composition-variables='[
      { "id": "glyphs", "type": "string", "role": "content", "label": "Glyphs", "description": "Stamp charset; one glyph per stamp is chosen deterministically.", "default": "░▒▓+·×" },
      { "id": "density", "type": "enum", "role": "style", "label": "Density", "description": "Arc-length spacing between stamps along the path.", "default": "med", "options": [{ "value": "low", "label": "Low" }, { "value": "med", "label": "Medium" }, { "value": "high", "label": "High" }] },
      { "id": "path", "type": "enum", "role": "timing", "label": "Path", "description": "Authored travel path preset.", "default": "sweep", "options": [{ "value": "sweep", "label": "Sweep" }, { "value": "arc", "label": "Arc" }, { "value": "zigzag", "label": "Zigzag" }] },
      { "id": "fade", "type": "number", "role": "timing", "label": "Fade", "description": "Per-stamp decay window in seconds, clamped 0.3 to 1.5.", "default": 0.8 },
      { "id": "accent", "type": "enum", "role": "style", "label": "Accent", "description": "Trail and actor 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 settled actor 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>Cursor Glyph Trail</title>
    </head>
    <body>
      <template>
        <div id="root" data-composition-id="cursor-glyph-trail" 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-mono, ui-monospace, "SF Mono", Menlo, Consolas, monospace);
              pointer-events: none;
            }

            .cgt-clip {
              position: absolute;
              inset: 0;
              overflow: hidden;
              background: var(--bg, transparent);
            }

            .cgt-stage {
              position: absolute;
              inset: 0;
              will-change: transform, opacity;
            }

            .cgt-field {
              position: absolute;
              inset: 0;
            }

            .cgt-stamp {
              position: absolute;
              opacity: 0;
              line-height: 1;
              font-weight: 500;
              will-change: opacity;
              user-select: none;
            }

            .cgt-actor {
              position: absolute;
              opacity: 0;
              transform: translate(-50%, -50%);
              will-change: opacity;
            }

            .cgt-dot {
              width: 3.4cqmin;
              height: 3.4cqmin;
              border-radius: 50%;
              background: var(--cgt-accent);
              border: 0.35cqmin solid color-mix(in srgb, var(--fg, #f8fafc) 82%, var(--cgt-accent));
              box-shadow: 0 0.6cqmin 2.4cqmin color-mix(in srgb, var(--bg, #000000) 35%, transparent);
            }
          </style>

          <div
            id="cursor-glyph-trail-clip"
            class="cgt-clip clip"
            data-start="0"
            data-duration="4"
            data-track-index="0"
          >
            <div class="cgt-stage">
              <div class="cgt-field" aria-hidden="true"></div>
              <div class="cgt-actor">
                <div class="cgt-dot"></div>
              </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(".cgt-stage");
              var field = root.querySelector(".cgt-field");
              var actor = root.querySelector(".cgt-actor");
              var vars =
                window.__hyperframes && window.__hyperframes.getVariables
                  ? window.__hyperframes.getVariables()
                  : {};

              // Arc-length spacing (percent-of-stage units). Fixed spacing
              // along the path means the per-second stamp rate rises and
              // falls with the actor's velocity: the density law.
              var spacingByDensity = { low: 4.6, med: 2.9, high: 1.8 };
              var density = Object.prototype.hasOwnProperty.call(spacingByDensity, vars.density)
                ? vars.density
                : "med";
              var pathName = vars.path === "arc" || vars.path === "zigzag" ? vars.path : "sweep";
              var glyphSource =
                typeof vars.glyphs === "string" && vars.glyphs.length > 0 ? vars.glyphs : "░▒▓+·×";
              var glyphSet = Array.from(glyphSource);
              var fadeRaw = parseFloat(vars.fade);
              var fade = isFinite(fadeRaw) ? Math.min(1.5, Math.max(0.3, fadeRaw)) : 0.8;
              // 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("--cgt-accent", accentColors[accent]);

              // SLOT. Caller templates live at HOST DOCUMENT level (the mount
              // wipes host-clip children, and templates never render).
              var hostDoc = root.ownerDocument;
              var slotTemplate = null;
              try {
                slotTemplate = hostDoc.querySelector(
                  'template[data-slot="cursor-glyph-trail-actor"]',
                );
              } catch (error) {
                slotTemplate = null;
              }
              if (slotTemplate) {
                actor.querySelector(".cgt-dot").remove();
                actor.appendChild(hostDoc.importNode(slotTemplate.content, true));
              }

              // Envelope: fixed IN/OUT, elastic HOLD, never time-scaled.
              var POP = 0.07;
              var TRAVEL_BASE = 2.4;
              var IN_BASE = TRAVEL_BASE + POP + fade * 1.25 + 0.05;
              var OUT_BASE = exit === "none" ? 0 : 0.45;
              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 TRAVEL = TRAVEL_BASE * scale;
              var pop = POP * scale;
              var fadeEff = fade * scale;
              var IN = IN_BASE * scale;
              var OUT = OUT_BASE * scale;
              var HOLD = Math.max(0, duration - (IN + OUT));
              var OUT_START = IN + HOLD;

              // Path presets in percent-of-stage coordinates.
              var zigzagPoints = [
                { x: 10, y: 34 },
                { x: 38, y: 70 },
                { x: 64, y: 32 },
                { x: 90, y: 64 },
              ];
              function pathPoint(u) {
                if (pathName === "arc") {
                  return { x: 12 + 76 * u, y: 68 - 40 * Math.sin(Math.PI * u) };
                }
                if (pathName === "zigzag") {
                  var segs = zigzagPoints.length - 1;
                  var s = Math.min(segs - 1e-9, Math.max(0, u * segs));
                  var i = Math.floor(s);
                  var f = s - i;
                  var a = zigzagPoints[i];
                  var b = zigzagPoints[i + 1];
                  return { x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f };
                }
                // sweep: an S-curve from lower left to upper right.
                return { x: 8 + 84 * u, y: 50 + 16 * Math.cos(Math.PI * u) };
              }

              // Shared travel ease, implemented once so the stamp table and
              // the per-update actor position use the identical mapping.
              function easeInOut(t) {
                return t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t);
              }
              function clamp01(v) {
                return v < 0 ? 0 : v > 1 ? 1 : v;
              }

              // The LCG and every per-stamp decision live here, computed once
              // before timeline construction from a fixed seed. A stamp's
              // opacity at any time is then a pure function of timeline time.
              var lcgState = 0x67117a11;
              function next() {
                lcgState = (Math.imul(1664525, lcgState) + 1013904223) >>> 0;
                return lcgState / 4294967296;
              }

              var spacing = spacingByDensity[density];
              var STEPS = 600;
              var stamps = [];
              var acc = 0;
              var prev = pathPoint(easeInOut(0));
              for (var k = 1; k <= STEPS; k += 1) {
                var f = k / STEPS;
                var pos = pathPoint(easeInOut(f));
                acc += Math.hypot(pos.x - prev.x, pos.y - prev.y);
                prev = pos;
                if (acc >= spacing) {
                  acc -= spacing;
                  stamps.push({
                    t: f * TRAVEL,
                    x: pos.x + (next() - 0.5) * 3.2,
                    y: pos.y + (next() - 0.5) * 3.2,
                    glyph: glyphSet[Math.floor(next() * glyphSet.length) % glyphSet.length],
                    rot: (next() - 0.5) * 20,
                    size: 1.9 + next() * 1.4,
                    peak: 0.45 + next() * 0.45,
                    mix: 55 + Math.floor(next() * 36),
                    fadeMul: 0.75 + next() * 0.5,
                  });
                }
              }

              var fragment = hostDoc.createDocumentFragment();
              for (var s = 0; s < stamps.length; s += 1) {
                var row = stamps[s];
                var span = hostDoc.createElement("span");
                span.className = "cgt-stamp";
                span.textContent = row.glyph;
                span.style.left = row.x.toFixed(3) + "%";
                span.style.top = row.y.toFixed(3) + "%";
                span.style.fontSize = row.size.toFixed(3) + "cqmin";
                span.style.transform = "translate(-50%, -50%) rotate(" + row.rot.toFixed(2) + "deg)";
                span.style.color =
                  "color-mix(in srgb, var(--cgt-accent) " + row.mix + "%, var(--fg, #f8fafc))";
                row.el = span;
                fragment.appendChild(span);
              }
              field.appendChild(fragment);

              // Pure per-update apply: the actor position and every stamp's
              // opacity are functions of tl.time() alone. A stamp pops in
              // over `pop` seconds and decays over its table row's window;
              // by IN every value is exactly zero. Writes are idempotent, so
              // the HOLD repaints identical values and stays truly still.
              function stampOpacity(row, timeSeconds) {
                var dt = timeSeconds - row.t;
                if (dt <= 0) return 0;
                if (dt < pop) {
                  var g = dt / pop;
                  return row.peak * (1 - (1 - g) * (1 - g));
                }
                var u = (dt - pop) / (fadeEff * row.fadeMul);
                if (u >= 1) return 0;
                return row.peak * (1 - u * u);
              }

              function applyTime(timeSeconds) {
                var u = easeInOut(clamp01(TRAVEL > 0 ? timeSeconds / TRAVEL : 1));
                var p = pathPoint(u);
                gsap.set(actor, { left: p.x.toFixed(4) + "%", top: p.y.toFixed(4) + "%" });
                for (var i = 0; i < stamps.length; i += 1) {
                  stamps[i].el.style.opacity = stampOpacity(stamps[i], timeSeconds).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 the full
              // authored duration so the timeline covers [0, D], onUpdate
              // fires for every eventful seek, and holds never clamp short.
              tl.to({ p: 0 }, { p: 1, duration: duration, ease: "none" }, 0);

              // Actor entrance: both endpoints authored.
              tl.fromTo(
                actor,
                { opacity: 0 },
                { opacity: 1, duration: 0.18 * scale, ease: "power2.out" },
                0,
              );

              // Stamps carry no tweens: their opacity rides applyTime's pure
              // table evaluation, and each decay completes inside IN by
              // construction.

              // HOLD: truly still. All stamps are exactly zero by IN and the
              // actor rests at path(1); 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["cursor-glyph-trail"] = tl;
            })();
          </script>
        </div>
      </template>
    </body>
  </html>
  ```
</Accordion>

Tagged `motion-primitive` `demonstrate` `trail` `cursor` `glyphs` `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

- [ASCII Trail Reveal](/catalog/components/ascii-trail-reveal.md)
- [Echo Trail](/catalog/components/echo-trail.md)
- [Simulated Cursor](/catalog/components/simulated-cursor.md)
