Exclusivo

Ghost Run <mv-ghost-run>

Progreso frente a tu propio intento anterior, para tareas que la gente repite: un fantasma translúcido reproduce tu mejor carrera (o la última, o la de un compañero, o la mediana del equipo que aporta la app) por el mismo recorrido en tiempo real, para que en cada momento veas si vas por delante o por detrás.

Referencia cultural

Mario Kart 64, Nintendo (1996, videojuego). En contrarreloj, un fantasma translúcido reproduce tu mejor vuelta exactamente como la condujiste, así que compites contra tu yo del pasado y ves en cada curva si vas por delante o por detrás. En la interfaz, cualquier tarea repetida se registra como puntos de control con marca de tiempo, el mejor intento o el último se reproduce como un marcador tenue sobre la misma pista y cada punto de control muestra el parcial frente a él.

Cómo funciona

Progreso frente a tu propio intento anterior, para tareas que se repiten: un fantasma translúcido reproduce tu mejor intento (o el último, o el de un compañero, o la mediana del equipo que proporciona la app) sobre la misma pista en tiempo real, así que en todo momento ves si vas por delante o por detrás. Un intento se registra como puntos de control con marca de tiempo, alimentados por checkpoint(id | value), una propiedad progress (los niveles de parcial se cruzan automáticamente) o elementos [data-ghost-mark] observados que pasan a marcados, hechos o visibles; en cada punto de control un parcial muestra la diferencia (“−1.8 s ahead of your best run”, “+3 min behind last week” o “+$203 ahead of September” cuando la diferencia se mide en progreso y no en tiempo), con chips de parcial por punto de control bajo la pista. Tres pistas: una barra con una banda rayada entre tú y el fantasma, un anillo para sesiones y listas de tareas, y un sparkline de progreso acumulado para objetivos con reloj de calendario (clock="manual"). Al pausar también se congela el fantasma, los intentos en curso pueden restaurarse tras recargar y, al terminar, un intento mejor se convierte discretamente en el nuevo fantasma (localStorage con storage-key, o tu servidor mediante el evento cancelable mv-save), mientras que uno más lento pregunta si quieres conservarlo.

CategoríaFeedback
TipoWeb Component (<mv-ghost-run>)
Estadoestable
También instalabutton
Keywordsexclusive, culture, progress, personal-best, ghost, pace, splits, time-trial, checkpoints, checklist, habit, drill, learning, goal, savings, sparkline, ring, gamification, comparison

When to use

  • A drill, lesson or practice session is repeated often and people improve by beating their own previous time
  • A recurring checklist or report (weekly close, pre-flight check, on-call handover) should be compared with last time as it is filled
  • A monthly or weekly goal (savings, sales, pages read) should show day by day whether it is ahead of last period
  • A team wants each person to see their pace against a teammate’s or the median run without a public leaderboard

Avoid when

  • The task is done once (a signup, a first setup) so there is no previous attempt to race → use Progress instead
  • Progress follows named steps the user navigates between, with no time or pace involved → use Stepper instead
  • Comparing with a past self would add pressure to a task where speed hurts quality or safety, such as medical or legal review

Instalación

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

Agente de IA con el servidor MCP de Marvelous UI: install_components({ slugs: ["ghost-run"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Archivos copiados (dependencias incluidas): tokens/tokens.css, core/base.css, components/button/button.css, core/dom.js, core/element.js, core/i18n.js, core/motion.js, core/observe.js, components/ghost-run/ghost-run.js, components/ghost-run/ghost-run.css.

Uso

Marcado de referencia: parte de él y personalízalo con atributos, data-* y variables CSS:

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

API

Attributes

NameTipoPredeterminadoDescripción
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. Locale: The value auto follows the page language (nearest lang); without the attribute, en-US unless the app registered the page language with setStrings() (core/i18n.js).
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

NameTipoDescripción
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

NameDescripción
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

NameDescripción
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

NameDescripción
(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

NameDescripción
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

NamePredeterminadoDescripción
--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.

Esta página se tradujo con IA. Informar de un problema de traducción