Exclusivebeta

Ghost Run — <mv-ghost-run>

Progress against your own previous attempt, for tasks people repeat: a translucent ghost replays your best run (or your last one, or a teammate’s or the team median supplied by the app) along the same track in real time, so at every moment you see whether you are ahead of it or behind. A run is recorded as timestamped checkpoints, fed by checkpoint(id | value), a progress property (split levels are crossed automatically) or watched [data-ghost-mark] elements that become checked, done or visible; at each checkpoint a split shows the gap (“−1.8 s ahead of your best run”, “+3 min behind last week”, or “+$203 ahead of September” when the gap is measured in progress rather than time), with per-checkpoint split chips under the track. Three tracks: a bar with a striped gap band between you and the ghost, a ring for sessions and checklists, and a sparkline of cumulative progress for goals on a calendar clock (clock="manual"). Pausing freezes the ghost too, runs in progress can be restored after a reload, and at the finish a better run silently becomes the new ghost (localStorage with storage-key, or your server via cancelable mv-save) while a slower one asks whether to keep it.

CategoryFeedback
TypeWeb Component (<mv-ghost-run>)
Statusbeta
Also installsbutton
Keywordsexclusive, culture, progress, personal-best, ghost, pace, splits, time-trial, checkpoints, checklist, habit, drill, learning, goal, savings, sparkline, ring, gamification, comparison

When to use

Avoid when

Install

node scripts/add.mjs ghost-run --out ./src/marvelous

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["ghost-run"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, core/observe.js, components/ghost-run/ghost-run.js, components/ghost-run/ghost-run.css, components/button/button.css.

Usage

Canonical markup — start from it and customize with attributes, data-* and CSS variables:

<div id="gr-demo" style="width:min(100%,60rem);margin-inline:auto">
  <style>
    #gr-demo { display:grid; gap:1rem; align-content:start }
    #gr-demo .gr-card { display:grid; gap:1rem; padding:1.125rem 1.25rem 1.25rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); min-width:0 }
    #gr-demo .gr-top { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap }
    #gr-demo .gr-top h3 { margin:0; font-size:1rem; letter-spacing:-.01em }
    #gr-demo .gr-top p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
    #gr-demo .gr-against { display:flex; align-items:center; gap:.5rem; color:var(--mv-fg-muted); font-size:.75rem }
    #gr-demo .gr-deck { display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:center; gap:1rem; padding:1rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-bg-subtle) }
    #gr-demo .gr-term { display:grid; gap:.25rem; min-width:0 }
    #gr-demo .gr-term small { color:var(--mv-fg-subtle); font-size:.6875rem; font-weight:600; letter-spacing:.05em; text-transform:uppercase }
    #gr-demo .gr-term strong { font-size:1.25rem; letter-spacing:-.015em }
    #gr-demo .gr-term span { min-height:1.25rem; color:var(--mv-fg-muted); font-size:.875rem }
    #gr-demo .gr-deck-actions { display:flex; gap:.5rem; flex-wrap:wrap; justify-content:flex-end }
    #gr-demo .gr-grid { display:grid; grid-template-columns:minmax(0,19rem) minmax(0,1fr); gap:1rem; align-items:start }
    #gr-demo .gr-list { display:grid; gap:.375rem; margin:0; padding:0; list-style:none }
    #gr-demo .gr-list li { padding:.5rem .625rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-md); background:var(--mv-surface); font-size:.8125rem; transition:background-color var(--mv-duration-fast) }
    #gr-demo .gr-list li:has(:checked) { background:var(--mv-bg-subtle); color:var(--mv-fg-muted) }
    #gr-demo .gr-list .mv-choice { width:100% }
    #gr-demo .gr-foot { display:flex; align-items:center; justify-content:space-between; gap:.5rem; flex-wrap:wrap }
    #gr-demo .gr-log { margin:0; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono) }
    #gr-demo .gr-actions { display:flex; gap:.5rem; flex-wrap:wrap }
    @media (max-width:46rem) { #gr-demo .gr-grid { grid-template-columns:minmax(0,1fr) } }
    @media (max-width:32rem) { #gr-demo .gr-deck { grid-template-columns:minmax(0,1fr) } #gr-demo .gr-deck-actions { justify-content:flex-start } }
  </style>

  <!-- Main: a flashcard review raced against your best, your last run or the team median -->
  <section class="gr-card" aria-labelledby="gr-cards-title">
    <div class="gr-top">
      <div>
        <h3 id="gr-cards-title">Spanish · Lesson 12, At the restaurant</h3>
        <p>Daily review: six cards, raced against a previous attempt.</p>
      </div>
      <span class="gr-against">
        <span id="gr-against-label">Race against</span>
        <mv-segmented id="gr-against" aria-labelledby="gr-against-label" value="best">
          <button value="best">My best</button>
          <button value="last">Last run</button>
          <button value="team">Team median</button>
        </mv-segmented>
      </span>
    </div>

    <mv-ghost-run id="gr-cards" label="Review · 6 cards" marks="check, waiter, ice, to-go, tip, recommend" controls tenths></mv-ghost-run>

    <div class="gr-deck">
      <div class="gr-term" aria-live="polite">
        <small id="gr-count">Card 3 of 6</small>
        <strong id="gr-word" lang="es">sin hielo</strong>
        <span id="gr-answer"></span>
      </div>
      <div class="gr-deck-actions">
        <button type="button" class="mv-button" data-variant="outline" data-size="sm" id="gr-show">Show answer</button>
        <button type="button" class="mv-button" data-size="sm" id="gr-got">Got it</button>
      </div>
    </div>
  </section>

  <div class="gr-grid">
    <!-- Ring: a drone pre-flight checklist watched through data-ghost-mark, raced against the last flight -->
    <section class="gr-card" aria-labelledby="gr-pre-title">
      <div class="gr-top">
        <div>
          <h3 id="gr-pre-title">Pre-flight check</h3>
          <p>Survey drone SX-4 · Pier 9</p>
        </div>
      </div>
      <mv-ghost-run id="gr-pre" variant="ring" against="last" ghost-label="last flight" watch="#gr-pre-list"></mv-ghost-run>
      <ul class="gr-list" id="gr-pre-list">
        <li data-ghost-mark="props"><label class="mv-choice"><input type="checkbox" class="mv-checkbox" checked> Propellers seated and locked</label></li>
        <li data-ghost-mark="battery"><label class="mv-choice"><input type="checkbox" class="mv-checkbox" checked> Battery above 90%</label></li>
        <li data-ghost-mark="gps"><label class="mv-choice"><input type="checkbox" class="mv-checkbox"> GPS lock, 12+ satellites</label></li>
        <li data-ghost-mark="rth"><label class="mv-choice"><input type="checkbox" class="mv-checkbox"> Return-to-home altitude set</label></li>
        <li data-ghost-mark="airspace"><label class="mv-choice"><input type="checkbox" class="mv-checkbox"> Airspace and site clear</label></li>
      </ul>
      <div class="gr-foot">
        <p class="gr-log" id="gr-pre-log">Tick the boxes to race your last check.</p>
        <button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="gr-pre-reset">Reset</button>
      </div>
    </section>

    <!-- Spark: a monthly savings goal on a manual (calendar) clock, raced against last month -->
    <section class="gr-card" aria-labelledby="gr-save-title">
      <div class="gr-top">
        <div>
          <h3 id="gr-save-title">Emergency fund</h3>
          <p>Goal: $1,500 a month · compared day by day with September</p>
        </div>
      </div>
      <mv-ghost-run id="gr-save" variant="spark" clock="manual" measure="progress" total="1500" currency="USD" span="30d" manual-finish label="October savings" ghost-label="September"></mv-ghost-run>
      <div class="gr-foot">
        <p class="gr-log" id="gr-save-log">Oct 18 · last deposit $120</p>
        <span class="gr-actions">
          <button type="button" class="mv-button" data-variant="outline" data-size="sm" id="gr-deposit">Deposit $50</button>
          <button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="gr-day">Next day</button>
        </span>
      </div>
    </section>
  </div>

  <script type="module">
    await customElements.whenDefined("mv-ghost-run");
    const S = 1000;
    const DAY = 864e5;

    /* ── Flashcards ─────────────────────────────────────── */
    const cards = [
      ["la cuenta", "the check"],
      ["el mesero", "the waiter"],
      ["sin hielo", "without ice"],
      ["para llevar", "to go"],
      ["la propina", "the tip"],
      ["¿Qué me recomienda?", "What do you recommend?"],
    ];
    const ids = ["check", "waiter", "ice", "to-go", "tip", "recommend"];
    const at = (times) => times.map((t, i) => ({ id: ids[i], p: (i + 1) / 6, t }));
    const best = { time: 41.8 * S, marks: at([5.8, 11.9, 18.8, 25.2, 33.4, 41.8].map((x) => x * S)), date: "2026-09-21" };
    const last = { time: 46.3 * S, marks: at([6.9, 14.2, 21.0, 29.4, 37.1, 46.3].map((x) => x * S)), date: "2026-09-22" };
    const team = { label: "Team median", name: "the team median", time: 43.5 * S, marks: at([6.2, 12.8, 19.9, 27.0, 34.8, 43.5].map((x) => x * S)) };

    const run = document.getElementById("gr-cards");
    const word = document.getElementById("gr-word");
    const answer = document.getElementById("gr-answer");
    const count = document.getElementById("gr-count");
    const got = document.getElementById("gr-got");
    const show = document.getElementById("gr-show");
    let index = 2;
    const paint = () => {
      const done = index >= cards.length;
      count.textContent = done ? "Deck complete" : `Card ${index + 1} of ${cards.length}`;
      word.textContent = done ? "¡Buen trabajo!" : cards[index][0];
      answer.textContent = "";
      got.disabled = show.disabled = done;
    };
    run.best = best;
    run.last = last;
    // A review already underway (restored after a reload): two cards done.
    run.start({ elapsed: 15.2 * S, marks: at([6.4, 13.1].map((x) => x * S)) });
    paint();

    show.addEventListener("click", () => { if (index < cards.length) answer.textContent = cards[index][1]; });
    got.addEventListener("click", () => {
      if (index >= cards.length) return;
      if (run.state === "paused") run.resume();
      run.checkpoint(ids[index]);
      index++;
      paint();
    });
    run.addEventListener("mv-start", (e) => {
      if (e.detail.reason === "again" || e.detail.reason === "button") { index = 0; paint(); }
    });
    document.getElementById("gr-against").addEventListener("mv-change", (e) => {
      const v = e.detail.value;
      run.ghost = v === "team" ? team : null;
      run.against = v === "last" ? "last" : "best";
    });

    /* ── Pre-flight checklist ───────────────────────────── */
    const pre = document.getElementById("gr-pre");
    const preLog = document.getElementById("gr-pre-log");
    const list = document.getElementById("gr-pre-list");
    const five = ["props", "battery", "gps", "rth", "airspace"];
    if (!pre.last) pre.last = { time: 24 * S, marks: [4.1, 9.3, 13.8, 18.9, 24].map((t, i) => ({ id: five[i], p: (i + 1) / 5, t: t * S })) };
    pre.start({ elapsed: 9.8 * S, marks: [{ id: "props", p: 0.2, t: 3.6 * S }, { id: "battery", p: 0.4, t: 8.7 * S }] });
    pre.addEventListener("mv-split", (e) => {
      const d = e.detail;
      if (d.delta === null) return;
      preLog.textContent = `${Math.round(d.progress * 5)} of 5 · ${d.delta < 0 ? "−" : "+"}${Math.abs(d.delta / S).toFixed(1)} s vs last flight`;
    });
    pre.addEventListener("mv-finish", (e) => {
      preLog.textContent = `Cleared for takeoff in ${(e.detail.time / S).toFixed(1)} s.`;
    });
    document.getElementById("gr-pre-reset").addEventListener("click", () => {
      pre.reset();
      for (const box of list.querySelectorAll("input")) box.checked = false;
      preLog.textContent = "The clock starts on the first tick.";
    });

    /* ── Savings goal (manual clock: one day per step) ──── */
    const save = document.getElementById("gr-save");
    const saveLog = document.getElementById("gr-save-log");
    const deposits = (list) => list.map(([day, amount]) => ({ p: amount / 1500, t: (day - 1) * DAY }));
    const oct = (ms) => new Date(2026, 9, 1 + Math.floor(ms / DAY));
    save.formatTime = (ms, { spoken }) => oct(ms).toLocaleDateString("en-US", spoken ? { month: "long", day: "numeric" } : { month: "short", day: "numeric" });
    save.last = { time: 29 * DAY, progress: 1320 / 1500, marks: deposits([[1, 200], [5, 350], [9, 420], [12, 560], [15, 700], [18, 780], [22, 980], [26, 1150], [30, 1320]]) };
    save.start({ elapsed: 17 * DAY, marks: deposits([[1, 250], [4, 400], [8, 520], [11, 640], [15, 810], [17, 930]]) });
    const dayLabel = () => save.formatTime(save.elapsed, {});
    document.getElementById("gr-deposit").addEventListener("click", () => {
      if (save.state === "finished") return;
      save.checkpoint(Math.min(1500, save.value + 50));
      saveLog.textContent = `${dayLabel()} · deposited $50`;
    });
    document.getElementById("gr-day").addEventListener("click", () => {
      if (save.state === "finished") return;
      save.elapsed = Math.min(30 * DAY, save.elapsed + DAY);
      saveLog.textContent = `${dayLabel()} · no deposit yet`;
      if (save.elapsed >= 30 * DAY) save.finish();
    });
  </script>
</div>

Cultural reference

Mario Kart 64 — Nintendo (1996, game). In Time Trial, a translucent ghost replays your best lap exactly as you drove it, so you race your past self and see at every corner whether you are ahead or behind. In the UI, any repeated task is recorded as timestamped checkpoints, the best or last run replays as a faint marker on the same track, and each checkpoint shows the split against it.

API

Attributes

NameTypeDefaultDescription
variantbar | ring | sparkbarTrack shape: a linear bar with split chips under each checkpoint, a ring with the split in its center, or a sparkline of cumulative progress over time (the ghost’s whole curve is drawn, yours grows over it).
againstbest | lastbestWhich stored run the ghost replays. Ignored while the ghost property holds an app-supplied run.
storage-keystringPersists the best and last runs in localStorage under mv-ghost-run:<key>. Without it they live in memory (or come from your server through the best / last properties).
marksnumber | comma listDeclared checkpoints when none are watched in the DOM: a count ("6") or ids ("intro, setup, invite") for checkpoint(id). Progress is the share of marks done, in any order.
watchCSS selectorRegion scanned for [data-ghost-mark] elements (default: the element’s own content). Elements added later are picked up.
data-ghost-markstring (on your elements)Declares a checkpoint. It is done when it is a checked checkbox or radio, contains a checked checkbox, or carries data-done / aria-checked="true" / aria-pressed="true".
data-ghost-whendone | checked | visible (on your elements)donevisible: the mark is done once 60% of it has been on screen (or it was scrolled past), for reading and course progress.
splitsnumber4Without declared marks, the progress property records a checkpoint each time it crosses one of these evenly spaced levels (25/50/75/100% by default).
autostartbooleanStarts the run the first time the element is visible. Otherwise a run starts with start(), the built-in control, or the first progress.
auto-pausebooleanPauses the run (and the ghost) while the page is hidden and resumes when it comes back.
manual-finishbooleanReaching 100% does not end the run; call finish() (end of the month, end of the session).
clockreal | manualrealmanual: time only moves when you set elapsed (calendar days, business days, turns). No ticker runs.
measuretime | progresstimeHow splits are expressed: time gap at the same progress (−12 s) or progress gap at the same time (+$203). A better run is faster (time) or further along (progress).
spantime ("30d", "90s", "1h30m", ms)Sparkline x-extent (otherwise the ghost’s duration, growing in steps when you run longer).
total / unit / currency / localenumber / string / ISO 4217 / BCP 47— / — / — / en-USProgress in real units: numeric checkpoints and value are in total units and are displayed as “$930 / $1,500” (currency) or “12 / 40 pages” (unit). Without total, progress is 0..1 shown as a percentage.
tenthsbooleanClock and splits to the tenth of a second (drills under a minute).
labelstringTitle shown in the head; also names the progressbar.
ghost-labelstringName of the ghost in sentences and the legend (“last flight”, “September”); capitalized in the legend.
controlsbooleanShows a built-in Start / Pause / Resume button and a “Race again” button in the result.
data-state / data-lead / data-split / data-has-ghostset by the componentidle | running | paused | finished; live position against the ghost (ahead | behind | even | none); lead of the last split (ahead | behind | even | beyond | none); present when a ghost exists. All styleable.

Properties

NameTypeDescription
ghostRun | nullApp-supplied ghost (a teammate, the team median, a coach’s reference). A run may carry label (legend) and name (in sentences). null goes back to best / last. Past splits are recomputed against the new ghost.
best / lastRun | nullStored runs; set them from your server, read them after a finish. Persisted with storage-key.
activeGhostRun | nullThe run currently replayed (read-only).
runRunThe current run, serializable: { time, progress, marks: [{ id?, p, t }], date }. Save it to restore later with start({ elapsed, marks }).
progress / valuenumberLive progress 0..1, or in total units. Setting it moves your marker and records the split levels it crosses; the first progress of an idle run starts it.
elapsednumber (ms)Run time, pause-aware. Set it to drive a manual clock.
state / split / leadread-onlyRun state; last split { index, id, progress, time, delta, ahead, lead, measure }; live lead.
stringsPartial<Record<string, string>>Overrides for every visible text and announcement (ahead, behind, even, ready, racing, first, finishedBetter, finishedWorse, keepPrompt, announceSplit…). English defaults.
formatTime / formatValue(ms, { kind, spoken, tenths }) => string / (value, { delta, spoken }) => stringCustom formatting, e.g. calendar dates for a manual clock (kind is "clock" or "delta").

Methods

NameDescription
start({ elapsed?, marks? })Starts a new run, or restores one in progress. Emits the cancelable mv-start first so the app can reset its own UI; what is already done in the watched DOM becomes the starting line.
checkpoint(idOrValue, { at? })Records a checkpoint: a declared mark id, or a value (total units, else 0..1). at = elapsed ms of the checkpoint (restoring, manual clocks). Returns the split or null.
pause() / resume()Freezes the clock and the ghost, then picks up where it stopped.
finish()Ends the run now (automatic at 100% unless manual-finish).
keep() / discard()Answers the “Keep this run as your ghost?” prompt shown after a slower run.
reset() / clearGhosts()Back to idle keeping the ghost / forget the stored best and last runs.

Events

NameDescription
mv-startCancelable, before a run starts. detail: { reason: "api" | "button" | "again" | "progress" | "view", restored }.
mv-splitA checkpoint was reached. detail: { index, id, progress, time, delta (ms, negative = ahead; or progress fraction, positive = ahead; null without a comparable ghost), ahead, lead, measure, against }.
mv-finishCancelable. detail: { time, delta, isBest (against your own best), run, ghost, against }. preventDefault() stores nothing and skips the prompt.
mv-saveCancelable, before a run is persisted. detail: { slot: "best" | "last", run }. Send it to your server; preventDefault() keeps it in memory only.
mv-pause / mv-resumedetail: { elapsed }.
mv-leadThe ghost overtook you or you overtook it between checkpoints (visual only, never announced). detail: { lead, elapsed }.

Content structure

NameDescription
(content)Optional: the task itself (a checklist with data-ghost-mark items) can live inside the element, after the generated head and track.

CSS classes

NameDescription
mv-ghost-run-hudGenerated block: -head (-label, -vs legend with -swatch, -count, -clock, -state, -toggle), -stage (-readout with -delta and -caption, -meter), -result.
mv-ghost-run-rail / -fill / -gap / -tick / -chipBar parts: rail, your fill, the gap band between you and the ghost, checkpoint notches, split chips (data-lead).
mv-ghost-run-ghost / -youThe ghost marker (dashed, translucent, with -ghost-tag) and your marker, in every variant.
mv-ghost-run-ring / -orbit / -centerRing parts (SVG arcs -ring-fill, -ring-ghost, -ring-gap, -ring-tick).
mv-ghost-run-plot / -spark-ghost / -spark-you / -now / -axisSparkline parts.
mv-ghost-run-resultFinish summary with -result-text, -result-note and the keep / discard / race again buttons.

CSS variables

NameDefaultDescription
--mv-ghost-run-colorvar(--mv-accent)Your fill and marker.
--mv-ghost-run-ghostcolor-mix(fg 42%, transparent)Ghost marker, arc and curve.
--mv-ghost-run-aheadvar(--mv-success)Ahead of the ghost (splits, gap band).
--mv-ghost-run-behindvar(--mv-warning)Behind the ghost.
--mv-ghost-run-size11.5remRing diameter.
--mv-ghost-run-height8.5remSparkline height.

Accessibility

The ghost is purely visual (aria-hidden markers, pointer-events: none) and has a text equivalent: the track is a role="progressbar" named by the label, whose aria-valuetext says where you are and how you stand (“Checkpoint 3 of 6. You are 1.8 seconds ahead of your best run.”), updated at checkpoints only, never per frame. Splits are announced once, politely, at each checkpoint (“Checkpoint 3 of 6: you are 1.8 seconds ahead of your best run.”); overtakes between checkpoints are never announced. Pause, resume and the finish summary are announced politely; the prompt after a slower run uses real buttons, and focus moves to the result only if it was on the built-in control that disappears. The clock is a role="timer" (not live), so it is read on demand. Ahead and behind are always spelled out in words and signs, never by color alone. Reduced motion (OS or data-motion="reduce"): no frame loop, the ghost jumps from checkpoint to checkpoint, markers and fills stop gliding and the paused clock stops blinking. Forced colors: your progress uses Highlight, the ghost GrayText. The frame loop only runs while a run is going, a ghost exists and the element is on screen.