Exclusivobeta

Ghost Run <mv-ghost-run>

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>)
Estadobeta
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, 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.

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>

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.

API

Attributes

NameTipoDefaultDescription
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

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

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