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

# Oscilloscope Trace

> CRT oscilloscope beam sweeping a waveform across a 10x8 graticule, with closed-form phosphor persistence: the tail decays as exp(-age/tau) and brightens where the beam slows

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

## Install

<InstallCommand command="npx hyperframes add oscilloscope-trace" />

That writes one file: `compositions/oscilloscope-trace.html`.

## Add it to your video

It runs for 6 seconds at 1920×1080. Paste this into your composition:

```html index.html theme={null}
<div
  data-composition-id="oscilloscope-trace"
  data-composition-src="compositions/oscilloscope-trace.html"
  data-start="0"
  data-duration="6"
  data-track-index="1"
  data-width="1920"
  data-height="1080"
></div>
```

Move it in time with `data-start`. Put it on a different timeline row with
`data-track-index`. See [data attributes](/concepts/data-attributes) for the rest.

## 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 |
| --------------- | --------- | --------------------------------------------- | ------------ |
| `waveform`      | `sine`    | `sine`, `square`, `triangle`, `data`          |              |
| `frequency`     | `12`      | 0.1Hz to 200Hz, step 0.1Hz                    |              |
| `amplitude`     | `3`       | 0.1div to 4div, step 0.1div                   |              |
| `persistenceMs` | `100`     | 1ms to 1000ms, step 1ms                       |              |
| `phosphorColor` | `#5dff8f` | color                                         |              |
| `sweepRate`     | `5`       | 0.25sweeps/s to 60sweeps/s, step 0.25sweeps/s |              |
| `dataSeries`    | \`\`      | string                                        |              |

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="oscilloscope-trace"
  data-composition-src="compositions/oscilloscope-trace.html"
  data-variable-values='{"waveform":"sine","frequency":12,"amplitude":3,"persistenceMs":100,"phosphorColor":"#5dff8f","sweepRate":5,"dataSeries":""}'
></div>
```

## Source

<Accordion title={`oscilloscope-trace.html`}>
  ```html theme={null}
  <!doctype html>
  <html
    lang="en"
    data-composition-variables='[
      {"id":"waveform","type":"enum","label":"Waveform","default":"sine","options":[{"value":"sine","label":"Sine"},{"value":"square","label":"Square"},{"value":"triangle","label":"Triangle"},{"value":"data","label":"Data series"}]},
      {"id":"frequency","type":"number","label":"Signal frequency","unit":"Hz","default":12,"min":0.1,"max":200,"step":0.1},
      {"id":"amplitude","type":"number","label":"Amplitude","unit":"div","default":3,"min":0.1,"max":4,"step":0.1},
      {"id":"persistenceMs","type":"number","label":"Phosphor persistence (tau)","unit":"ms","default":100,"min":1,"max":1000,"step":1},
      {"id":"phosphorColor","type":"color","label":"Phosphor colour","default":"#5dff8f"},
      {"id":"sweepRate","type":"number","label":"Sweep rate","unit":"sweeps/s","default":5,"min":0.25,"max":60,"step":0.25},
      {"id":"dataSeries","type":"string","label":"Data series (waveform=data)","default":"","placeholder":"0,0.4,0.9,0.3,-0.6,-1,-0.2,0.5"}
    ]'
  >
    <head>
      <meta charset="UTF-8" />
      <meta name="viewport" content="width=1920, height=1080" />
      <title>Oscilloscope Trace</title>
      <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
      <style>
        * {
          margin: 0;
          padding: 0;
          box-sizing: border-box;
        }
        html,
        body {
          width: 1920px;
          height: 1080px;
          overflow: hidden;
          background: #05070a;
        }
        #os-root {
          position: relative;
          width: 1920px;
          height: 1080px;
          font-family: "JetBrains Mono", ui-monospace, monospace;
        }
        #os-bg {
          position: absolute;
          inset: 0;
          background: radial-gradient(120% 120% at 30% 25%, #0d141b 0%, #05070a 68%);
        }
        #os-screen {
          position: absolute;
          inset: 0;
          width: 1920px;
          height: 1080px;
          display: block;
        }
        /* Purely decorative CRT falloff — no timing, no motion. */
        #os-vignette {
          position: absolute;
          inset: 0;
          pointer-events: none;
          background: radial-gradient(
            78% 78% at 34% 50%,
            rgba(0, 0, 0, 0) 55%,
            rgba(0, 0, 0, 0.55) 100%
          );
        }
        #os-readout {
          position: absolute;
          left: 1250px;
          top: 150px;
          width: 520px;
          color: #6f8794;
          font-size: 26px;
          line-height: 1.15;
          letter-spacing: 0.04em;
        }
        #os-readout .os-title {
          color: #b9ccd6;
          font-size: 34px;
          letter-spacing: 0.22em;
          padding-bottom: 26px;
          border-bottom: 2px solid #1d2a33;
          margin-bottom: 26px;
        }
        #os-readout .os-row {
          display: flex;
          justify-content: space-between;
          padding: 13px 0;
        }
        #os-readout .os-val {
          color: #d7e6ee;
        }
        #os-readout .os-note {
          margin-top: 28px;
          font-size: 20px;
          color: #6a7b85;
          line-height: 1.5;
        }
      </style>
    </head>
    <body>
      <div
        id="os-root"
        data-composition-id="oscilloscope-trace"
        data-start="0"
        data-duration="6"
        data-width="1920"
        data-height="1080"
      >
        <div id="os-bg"></div>
        <canvas
          id="os-screen"
          class="clip"
          width="1920"
          height="1080"
          data-start="0"
          data-duration="6"
          data-track-index="0"
        ></canvas>
        <div id="os-vignette"></div>
        <div id="os-readout" class="clip" data-start="0" data-duration="6" data-track-index="1">
          <div class="os-title">OSCILLOSCOPE</div>
          <div class="os-row"><span>SOURCE</span><span class="os-val" id="os-r-wave">SINE</span></div>
          <div class="os-row"><span>FREQ</span><span class="os-val" id="os-r-freq">-</span></div>
          <div class="os-row"><span>TIMEBASE</span><span class="os-val" id="os-r-time">-</span></div>
          <div class="os-row"><span>VERT</span><span class="os-val" id="os-r-amp">-</span></div>
          <div class="os-row"><span>PHOSPHOR</span><span class="os-val" id="os-r-phos">-</span></div>
          <div class="os-note" id="os-r-note"></div>
        </div>
      </div>
      <script>
        (function () {
          "use strict";

          // ---------------------------------------------------------------
          // Oscilloscope trace with phosphor persistence.
          //
          // Two pieces of physical truth drive the look:
          //
          //  1. PERSISTENCE. The phosphor keeps emitting after the beam has
          //     passed, decaying as exp(-age/tau). Decay constants by EIA
          //     phosphor class (research entry F#10):
          //       P11/P31 - 0.01-1 ms   (what a real bench scope uses: no
          //                              visible afterglow at video rates)
          //       P1      - 80-150 ms   (the "vintage glow" look; tau ~= 100 ms
          //                              is the entry's recommended default)
          //       P33     - > 1 s
          //       P2/P7   - 30 s .. ~1 min (radar territory, out of range here)
          //     The tail window is 3*tau: after three time constants ~5% of the
          //     brightness remains, which the entry gives as the safe cutoff.
          //
          //  2. BEAM-VELOCITY BRIGHTNESS. A trace is brighter where the beam
          //     moves slower, because the same deposited energy is spread over
          //     a shorter path. Each sub-step deposits a fixed amount of energy
          //     (constant dt), so surface brightness goes as 1/segment-length.
          //     Flat parts of the waveform are bright; fast vertical edges are
          //     faint. Qualitative law only - the research entry states it with
          //     no proportionality constant, so the reference length below is
          //     the physical minimum (pure horizontal sweep motion), not a
          //     fudge factor.
          //
          // SWEEP RATE PROVENANCE. The phosphor decay constants above are
          // measured; the sweep rate is NOT - the research entry gives no
          // timebase figure. The default of 5 sweeps/s is authored: across the
          // 10-division graticule it works out to 20 ms/div, a real value from
          // the standard 1-2-5 timebase sequence, picked because it puts a few
          // cycles of the default signal on screen. Treat it as a dial, not as
          // a measurement.
          //
          // NO FEEDBACK BUFFER. The canvas is cleared every frame and the tail
          // is recomputed by evaluating the beam curve backwards in time from
          // the current frame: P(t - k*dt) for k = 0..K. Frame N depends only
          // on N, so seeking anywhere is exact rather than approximate.
          // ---------------------------------------------------------------

          var COMP_ID = "oscilloscope-trace";
          var DURATION = 6;

          // Screen geometry: a 10x8 division graticule of SQUARE divisions,
          // the standard CRT scope face.
          var DIV = 100;
          var DIVS_X = 10;
          var DIVS_Y = 8;
          var SW = DIV * DIVS_X;
          var SH = DIV * DIVS_Y;
          var SX = 140;
          var SY = 140;
          var CY = SY + SH / 2;

          // Tail window in time constants (research entry F#10: 3*tau leaves
          // ~5% weight, stated there as the safe cutoff).
          var TAIL_TAUS = 3;
          // Sub-step count. The entry suggests K ~= 24 steps across the window,
          // which is enough to quantise the DECAY but far too coarse spatially:
          // at 12 Hz the beam would advance 0.15 of a cycle per step and the
          // trace would render as a polygon. So K is derived from how fast the
          // beam actually moves - keep each sub-step under SEG_TARGET_PX of
          // travel. That target and the clamps are MY numbers, not measured
          // ones; the clamp bounds per-frame cost when persistence is long.
          var SEG_TARGET_PX = 3;
          var K_MIN = 120;
          var K_MAX = 8000;

          // Band-limit for the square wave. A real generator + a real scope
          // front end both have finite bandwidth, so the edge is steep but not
          // instantaneous; a mathematical step would make the vertical edge one
          // sub-step long and the velocity law would erase it entirely.
          var SQUARE_SHARPNESS = 8;

          function readVariables() {
            var api = window.__hyperframes && window.__hyperframes.getVariables;
            if (typeof api === "function") return api() || {};
            // Standalone fallback (raw file opened without the runtime): read
            // the same declaration the runtime reads, so defaults have exactly
            // one home.
            var out = {};
            try {
              var raw = document.documentElement.getAttribute("data-composition-variables");
              var decls = JSON.parse(raw || "[]");
              for (var i = 0; i < decls.length; i++) out[decls[i].id] = decls[i].default;
            } catch (err) {
              /* declaration missing or malformed - fall through to hard defaults */
            }
            return out;
          }

          function num(value, fallback, min, max) {
            var n = typeof value === "number" ? value : parseFloat(value);
            if (!isFinite(n)) n = fallback;
            return Math.min(max, Math.max(min, n));
          }

          var V = readVariables();
          var waveform =
            ["sine", "square", "triangle", "data"].indexOf(String(V.waveform)) >= 0
              ? String(V.waveform)
              : "sine";
          var frequency = num(V.frequency, 12, 0.1, 200);
          var amplitude = num(V.amplitude, 3, 0.1, 4);
          var tau = num(V.persistenceMs, 100, 1, 1000) / 1000;
          var sweepRate = num(V.sweepRate, 5, 0.25, 60);
          var phosphor = parseColor(V.phosphorColor, [93, 255, 143]);

          // waveform="data" replays a supplied series instead of a synthetic
          // shape: comma/whitespace separated numbers, clamped to -1..1, linearly
          // interpolated, one full pass of the series per `frequency` cycle.
          // Empty or unparseable -> falls back to sine.
          var series = String(V.dataSeries == null ? "" : V.dataSeries)
            .split(/[\s,]+/)
            .map(parseFloat)
            .filter(function (n) {
              return isFinite(n);
            })
            .map(function (n) {
              return Math.min(1, Math.max(-1, n));
            });
          if (waveform === "data" && series.length < 2) waveform = "sine";

          function parseColor(value, fallback) {
            var m = /^#?([0-9a-f]{6})$/i.exec(String(value == null ? "" : value).trim());
            if (!m) return fallback;
            var v = parseInt(m[1], 16);
            return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
          }

          function rgba(c, a) {
            return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a.toFixed(4) + ")";
          }

          // --- the signal: pure function of time, no state ------------------

          function wave(t) {
            var p = frequency * t; // cycles elapsed
            if (waveform === "square") {
              return Math.tanh(SQUARE_SHARPNESS * Math.sin(2 * Math.PI * p));
            }
            if (waveform === "triangle") {
              return (2 / Math.PI) * Math.asin(Math.sin(2 * Math.PI * p));
            }
            if (waveform === "data") {
              var frac = p - Math.floor(p);
              var pos = frac * (series.length - 1);
              var i = Math.floor(pos);
              var f = pos - i;
              var a = series[i];
              var b = series[Math.min(series.length - 1, i + 1)];
              return a + (b - a) * f;
            }
            return Math.sin(2 * Math.PI * p);
          }

          /** Horizontal sweep position, 0..1 across the graticule. */
          function sweepU(t) {
            var s = t * sweepRate;
            return s - Math.floor(s);
          }

          function beamX(t) {
            return SX + sweepU(t) * SW;
          }

          function beamY(t) {
            return CY - wave(t) * amplitude * DIV;
          }

          // Upper bound on beam speed, in px/s, used to pick the sub-step count.
          // Horizontal is the constant sweep; vertical is the waveform's steepest
          // slope, which differs per shape - a band-limited square is
          // SQUARE_SHARPNESS times steeper at its edge than a sine of the same
          // frequency, and under-sampling exactly there is what turns the edge
          // into a polygon.
          var slopeBound;
          if (waveform === "square") {
            slopeBound = SQUARE_SHARPNESS * 2 * Math.PI * frequency;
          } else if (waveform === "triangle") {
            slopeBound = 4 * frequency;
          } else if (waveform === "data") {
            var maxStep = 0;
            for (var si = 1; si < series.length; si++) {
              maxStep = Math.max(maxStep, Math.abs(series[si] - series[si - 1]));
            }
            slopeBound = maxStep * (series.length - 1) * frequency;
          } else {
            slopeBound = 2 * Math.PI * frequency;
          }
          var beamSpeedMax = Math.sqrt(
            Math.pow(SW * sweepRate, 2) + Math.pow(slopeBound * amplitude * DIV, 2),
          );

          // --- graticule (drawn once to an offscreen canvas) ----------------

          var grat = document.createElement("canvas");
          grat.width = 1920;
          grat.height = 1080;
          (function drawGraticule() {
            var g = grat.getContext("2d");
            g.fillStyle = "#04080a";
            g.fillRect(SX, SY, SW, SH);
            g.strokeStyle = "rgba(120,180,160,0.16)";
            g.lineWidth = 1;
            for (var i = 1; i < DIVS_X; i++) {
              g.beginPath();
              g.moveTo(SX + i * DIV + 0.5, SY);
              g.lineTo(SX + i * DIV + 0.5, SY + SH);
              g.stroke();
            }
            for (var j = 1; j < DIVS_Y; j++) {
              g.beginPath();
              g.moveTo(SX, SY + j * DIV + 0.5);
              g.lineTo(SX + SW, SY + j * DIV + 0.5);
              g.stroke();
            }
            // Centre axes carry the fine 0.2-division ticks, as on a real face.
            g.strokeStyle = "rgba(150,205,185,0.32)";
            var cx = SX + SW / 2 + 0.5;
            var cy = CY + 0.5;
            g.beginPath();
            g.moveTo(cx, SY);
            g.lineTo(cx, SY + SH);
            g.moveTo(SX, cy);
            g.lineTo(SX + SW, cy);
            g.stroke();
            g.strokeStyle = "rgba(150,205,185,0.42)";
            for (var k = 1; k < DIVS_X * 5; k++) {
              var x = SX + (k * DIV) / 5 + 0.5;
              g.beginPath();
              g.moveTo(x, cy - 9);
              g.lineTo(x, cy + 9);
              g.stroke();
            }
            for (var m = 1; m < DIVS_Y * 5; m++) {
              var y = SY + (m * DIV) / 5 + 0.5;
              g.beginPath();
              g.moveTo(cx - 9, y);
              g.lineTo(cx + 9, y);
              g.stroke();
            }
            g.strokeStyle = "rgba(160,215,195,0.55)";
            g.lineWidth = 2;
            g.strokeRect(SX + 1, SY + 1, SW - 2, SH - 2);
          })();

          // --- per-frame paint ----------------------------------------------

          var canvas = document.getElementById("os-screen");
          var ctx = canvas.getContext("2d");

          function draw(t) {
            ctx.globalCompositeOperation = "source-over";
            ctx.clearRect(0, 0, 1920, 1080);
            ctx.drawImage(grat, 0, 0);

            var windowS = TAIL_TAUS * tau;
            var K = Math.max(
              K_MIN,
              Math.min(K_MAX, Math.round((windowS * beamSpeedMax) / SEG_TARGET_PX)),
            );
            var dt = windowS / K;
            // Length the beam covers in one sub-step with zero vertical motion.
            // That is the slowest the beam can ever move, so it is the maximum
            // brightness reference and the velocity weight never exceeds 1.
            var lRef = SW * sweepRate * dt;

            ctx.globalCompositeOperation = "lighter";
            ctx.lineCap = "round";

            // Oldest -> newest, so the bright head lands on top.
            var prevX = 0;
            var prevY = 0;
            var prevU = 0;
            var havePrev = false;
            for (var k = K; k >= 0; k--) {
              var tk = t - k * dt;
              if (tk < 0) {
                // Before frame 0 the beam had not been switched on yet.
                havePrev = false;
                continue;
              }
              var u = sweepU(tk);
              var x = SX + u * SW;
              var y = beamY(tk);
              if (havePrev && u >= prevU) {
                var dx = x - prevX;
                var dy = y - prevY;
                var len = Math.sqrt(dx * dx + dy * dy);
                // Beam-velocity law: brightness ~ 1 / path length per unit time.
                var vel = len > 1e-6 ? Math.min(1, lRef / len) : 1;
                var decay = Math.exp((-k * dt) / tau);
                var a = decay * vel;
                if (a > 0.002) {
                  ctx.beginPath();
                  ctx.moveTo(prevX, prevY);
                  ctx.lineTo(x, y);
                  ctx.strokeStyle = rgba(phosphor, a * 0.13);
                  ctx.lineWidth = 13;
                  ctx.stroke();
                  ctx.strokeStyle = rgba(phosphor, a * 0.9);
                  ctx.lineWidth = 3;
                  ctx.stroke();
                  if (a > 0.55) {
                    // Overdriven phosphor saturates towards white at the head.
                    ctx.strokeStyle =
                      "rgba(255,255,255," + (((a - 0.55) / 0.45) * 0.7).toFixed(4) + ")";
                    ctx.lineWidth = 1.4;
                    ctx.stroke();
                  }
                }
              }
              // Retrace is blanked on a real scope: when u wraps, drop the
              // connecting segment instead of drawing a line back across.
              prevX = x;
              prevY = y;
              prevU = u;
              havePrev = true;
            }

            // Beam head: the spot itself, brightest point on the screen.
            var hx = beamX(t);
            var hy = beamY(t);
            var glow = ctx.createRadialGradient(hx, hy, 0, hx, hy, 22);
            glow.addColorStop(0, "rgba(255,255,255,0.95)");
            glow.addColorStop(0.22, rgba(phosphor, 0.85));
            glow.addColorStop(1, rgba(phosphor, 0));
            ctx.fillStyle = glow;
            ctx.beginPath();
            ctx.arc(hx, hy, 22, 0, Math.PI * 2);
            ctx.fill();

            ctx.globalCompositeOperation = "source-over";
          }

          // --- readout --------------------------------------------------------

          (function fillReadout() {
            // Nearest EIA phosphor class for the chosen tau (bands from the
            // research entry). Labelling, not simulation.
            var tauMs = tau * 1000;
            var cls = tauMs <= 1 ? "P31" : tauMs <= 150 ? "P1" : "P33";
            var msPerDiv = 1000 / (sweepRate * DIVS_X);
            function set(id, text) {
              document.getElementById(id).textContent = text;
            }
            set("os-r-wave", waveform.toUpperCase());
            set("os-r-freq", frequency.toFixed(frequency < 10 ? 2 : 1) + " Hz");
            set(
              "os-r-time",
              (msPerDiv >= 10 ? msPerDiv.toFixed(0) : msPerDiv.toFixed(2)) + " ms/div",
            );
            set("os-r-amp", amplitude.toFixed(1) + " div pk");
            set("os-r-phos", cls + "  tau " + tauMs.toFixed(0) + " ms");
            set(
              "os-r-note",
              "Trace brightness falls as exp(-age/tau) behind the beam, and rises where the beam slows.",
            );
          })();

          // --- timeline -------------------------------------------------------
          //
          // tl.eventCallback("onUpdate", ...) is NOT usable here: the runtime
          // seeks with suppressEvents, so the callback never fires on a seek and
          // the canvas would keep whatever the last played frame drew. A tweened
          // property with an accessor is applied by GSAP on every render,
          // including suppressed ones, so the repaint is driven from the setter.

          var beam = { t: 0 };
          var driver = {};
          Object.defineProperty(driver, "t", {
            get: function () {
              return beam.t;
            },
            set: function (value) {
              beam.t = value;
              draw(value);
            },
          });

          window.__timelines = window.__timelines || {};
          var tl = gsap.timeline({ paused: true });
          tl.to(driver, { t: DURATION, duration: DURATION, ease: "none", lazy: false }, 0);
          window.__timelines[COMP_ID] = tl;

          draw(0);
        })();
      </script>
    </body>
  </html>
  ```
</Accordion>

Tagged `instrument` `data` `showcase` `retro`.

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

- [SVG Stroke Trace](/catalog/components/svg-stroke-trace.md)
- [Code changes and PRs](/prompting/code-and-prs.md)
