Exclusivobeta
Steady Pace <mv-steady-pace>
Seguimiento del ritmo hacia una meta que te dice el ritmo constante necesario a partir de hoy, no solo un porcentaje: metas de ahorro, cuotas de ventas, metas de lectura o aprendizaje, recaudación de fondos, alcance de un sprint, planes de entrenamiento. Dale un objetivo, un inicio, una fecha límite y entradas con fecha (importes, o totales acumulados con entry-type="total"), y compara dónde estás con la “línea constante” uniforme del inicio a la fecha límite: por delante o por detrás en unidades y en días (“194 $ por detrás de la línea constante”, “4 días de retraso”), el ritmo necesario desde hoy, redondeado al alza para que seguirlo siempre termine a tiempo (“54 $/día para llegar a 4000 $ el 1 de nov.”), si tus últimos 7 y 14 días bastan (pequeños medidores con una muesca en el ritmo necesario y un veredicto en palabras), y dónde te lleva tu ritmo reciente (“terminarías hacia el 15 de nov., 14 días después de la fecha límite”). Cuando la mayor parte del progreso llegó en uno o dos días seguidos de una semana tranquila, una nota amable lo dice y da la pequeña cantidad diaria que mantiene las cosas estables, nunca un aviso. La variante de gráfico dibuja el progreso real acumulado como una escalera, la línea constante, la línea necesaria desde hoy y la proyección del ritmo reciente con su fecha de fin etiquetada, con una lectura al pasar el puntero y una tabla de datos real tras un desplegable. Un slider de simulación prueba otra cantidad diaria con dos reglas: mantener la fecha límite (lo que tendrías para entonces) o mover la fecha (cuándo terminarías). Recuento solo de días laborables con festivos, visualización por día, semana o mes, fechas relativas (“+37d”, “-2w”) y actualización a medianoche. Tres variantes: una frase inline, una tarjeta y un gráfico completo.
| Categoría | Visualización de datos |
|---|---|
| Tipo | Web Component (<mv-steady-pace>) |
| Estado | beta |
| Kit | Cifras que se explican solas |
| También instala | slider |
| Keywords | exclusive, culture, pacing, goal, target, deadline, quota, savings, fundraising, burn-up, burndown, run-rate, required-rate, habit, streak, training-plan, sprint, what-if, chart, workdays, projection |
When to use
- A savings, fundraising or budget goal should say how much to put in per day or week from now on, not only how far along it is
- A sales team tracks a quarterly quota in workdays and needs to know if it is ahead or behind and what weekly pace closes the gap
- A reading, learning or training plan should flag progress that came in bursts and suggest a small steady daily amount instead
- A goals list or dashboard row needs a one-line pacing status with the required rate, in a sentence rather than a chart
Avoid when
- There is no deadline: the value only needs to show how far along a task is → use Progress instead
- The question is when an open-ended metric reaches a goal on its trend, with levers and an uncertainty range → use Yet To Come instead
- People repeat the same task and want to race their own previous attempt rather than a calendar → use Ghost Run instead
Instalación
node scripts/add.mjs steady-pace --out ./src/marvelousAgente de IA con el servidor MCP de Marvelous UI: install_components({ slugs: ["steady-pace"], 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/steady-pace/steady-pace.js, components/steady-pace/steady-pace.css, components/slider/slider.js, components/slider/slider.css.
Uso
Inicio rápido, el marcado mínimo que funciona:
<mv-steady-pace label="Emergency fund" format="currency" target="4000" start="-45d" deadline="+37d">
<script type="application/json" data-entries>[["-44d", 300], ["-31d", 300], ["-17d", 300], ["-3d", 300]]</script>
</mv-steady-pace>Marcado de referencia: parte de él y personalízalo con atributos, data-* y variables CSS:
<div id="sp-demo" style="width:min(100%,68rem);margin-inline:auto">
<style>
#sp-demo { display:grid; gap:1.75rem }
#sp-demo .spd-bar { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:.75rem 1rem }
#sp-demo .spd-bar h3 { margin:0; font-size:1.0625rem; letter-spacing:-.01em }
#sp-demo .spd-bar p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#sp-demo .spd-actions { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem .75rem }
#sp-demo .spd-col { display:grid; gap:.75rem; min-width:0; align-content:start }
#sp-demo .spd-row { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1.5rem; align-items:start }
#sp-demo .spd-log { display:flex; align-items:center; gap:.5rem; min-width:0; color:var(--mv-fg-muted); font-size:.75rem }
#sp-demo .spd-log span:last-child { overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
#sp-demo .spd-goals { margin:0; padding:0; list-style:none; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs) }
#sp-demo .spd-goals li { display:grid; grid-template-columns:minmax(9rem,13rem) minmax(0,1fr); align-items:center; gap:.5rem 1.25rem; padding:.875rem 1.125rem; font-size:.875rem }
#sp-demo .spd-goals li + li { border-top:1px solid var(--mv-border) }
#sp-demo .spd-goal { display:grid; gap:.125rem; min-width:0 }
#sp-demo .spd-goal strong { font-weight:600 }
#sp-demo .spd-goal span { color:var(--mv-fg-muted); font-size:.75rem }
@media (max-width:52rem) {
#sp-demo .spd-row { grid-template-columns:minmax(0,1fr) }
#sp-demo .spd-goals li { grid-template-columns:minmax(0,1fr) }
}
</style>
<!-- Main: a savings goal, full chart with a what-if -->
<section class="spd-col" aria-labelledby="spd-main-h">
<div class="spd-bar">
<div>
<h3 id="spd-main-h">Emergency fund</h3>
<p>Personal finance app · deposits since mid-August</p>
</div>
<div class="spd-actions">
<mv-segmented id="spd-view" aria-label="View" value="chart">
<button value="inline">Inline</button>
<button value="card">Card</button>
<button value="chart">Chart</button>
</mv-segmented>
<label class="mv-choice" data-control="end">
<input type="checkbox" role="switch" class="mv-switch" id="spd-workdays">
<span class="mv-choice-text"><span class="mv-choice-title">Workdays only</span></span>
</label>
<button class="mv-button" data-size="sm" type="button" id="spd-log-btn">Log $75 today</button>
</div>
</div>
<mv-steady-pace id="spd-main" label="Save $4,000 for an emergency fund" format="currency" target="4000" start="-45d" deadline="+37d" variant="chart" what-if="fixed">
<script type="application/json" data-entries>[
["-44d", 300, "Paycheck"], ["-38d", 180], ["-31d", 300, "Paycheck"], ["-27d", 150], ["-24d", 90],
["-20d", 100], ["-17d", 300, "Paycheck"], ["-12d", 120], ["-9d", 60], ["-3d", 300, "Paycheck"], ["-1d", 75]
]</script>
</mv-steady-pace>
<div class="spd-log" aria-hidden="true"><span class="mv-badge" data-variant="secondary" data-shape="pill" id="spd-event">mv-pace-change</span><span id="spd-last">Waiting for an event…</span></div>
</section>
<div class="spd-row">
<!-- A sales quota counted in workdays, shown per week; two big deals then a quiet stretch -->
<section class="spd-col" aria-labelledby="spd-quota-h">
<div class="spd-bar"><div><h3 id="spd-quota-h">Q4 sales quota</h3><p>Workdays only · pace per week · burst detection</p></div></div>
<mv-steady-pace label="Team quota" format="currency" target="180000" start="-24d" deadline="+60d" working-days per="week">
<script type="application/json" data-entries>[
["-22d", 42000, "Northwind renewal"], ["-19d", 38500, "Globex expansion"], ["-15d", 4200], ["-9d", 6500], ["-6d", 2800], ["-2d", 3600]
]</script>
</mv-steady-pace>
</section>
<!-- A learning goal in custom units, with the "move the date" what-if -->
<section class="spd-col" aria-labelledby="spd-words-h">
<div class="spd-bar"><div><h3 id="spd-words-h">Spanish vocabulary</h3><p>Custom unit · what-if that moves the date</p></div></div>
<mv-steady-pace label="Learn 1,500 words" unit="words" target="1500" start="-20d" deadline="+10d" what-if="extend">
<script type="application/json" data-entries>[
["-20d", 60], ["-19d", 45], ["-18d", 70], ["-17d", 50], ["-15d", 80], ["-14d", 40], ["-13d", 55], ["-11d", 65],
["-10d", 50], ["-8d", 75], ["-7d", 60], ["-6d", 45], ["-4d", 90], ["-3d", 70], ["-2d", 55], ["-1d", 60], ["today", 70]
]</script>
</mv-steady-pace>
</section>
</div>
<!-- Inline sentences inside a goals list -->
<section class="spd-col" aria-labelledby="spd-goals-h">
<div class="spd-bar"><div><h3 id="spd-goals-h">Team goals</h3><p>Inline variant: one status and one sentence per row</p></div></div>
<ul class="spd-goals">
<li>
<span class="spd-goal"><strong>Sprint 42 scope</strong><span>Engineering · workdays</span></span>
<mv-steady-pace variant="inline" unit="points" target="40" start="-6d" deadline="+7d" working-days>
<script type="application/json" data-entries>[["-5d", 5], ["-4d", 3], ["-2d", 4], ["-1d", 4]]</script>
</mv-steady-pace>
</li>
<li>
<span class="spd-goal"><strong>Community fundraiser</strong><span>Riverside food bank</span></span>
<mv-steady-pace variant="inline" format="currency" target="25000" start="-30d" deadline="+30d">
<script type="application/json" data-entries>[["-29d", 6000, "Launch event"], ["-27d", 1200], ["-20d", 800], ["-14d", 650], ["-9d", 400], ["-4d", 750]]</script>
</mv-steady-pace>
</li>
<li>
<span class="spd-goal"><strong>Marathon training block</strong><span>Aiko Tanaka · distance</span></span>
<mv-steady-pace variant="inline" unit="km" target="400" start="-50d" deadline="+6d">
<script type="application/json" data-entries>[["-49d", 60], ["-40d", 70], ["-31d", 75], ["-22d", 80], ["-12d", 70], ["-3d", 45]]</script>
</mv-steady-pace>
</li>
</ul>
</section>
<script type="module">
await customElements.whenDefined("mv-steady-pace");
const main = document.getElementById("spd-main");
const badge = document.getElementById("spd-event");
const last = document.getElementById("spd-last");
main.addEventListener("mv-pace-change", (e) => {
const { tier, previous, statement, label } = e.detail;
if (previous === null) return;
badge.textContent = "mv-pace-change";
badge.dataset.variant = tier === "ahead" || tier === "done" ? "success" : tier === "on-pace" ? "info" : "warning";
last.textContent = `${previous} → ${tier}: ${label}. ${statement}`;
});
main.addEventListener("mv-whatif", (e) => {
badge.textContent = "mv-whatif";
badge.dataset.variant = "secondary";
last.textContent = e.detail.statement;
});
document.getElementById("spd-log-btn").addEventListener("click", () => main.add(75));
document.getElementById("spd-workdays").addEventListener("change", (e) => { main.workingDays = e.target.checked; });
document.getElementById("spd-view").addEventListener("mv-change", (e) => { main.variant = e.detail.value; });
</script>
</div>Referencia cultural
La liebre y la tortuga, Esopo (c. siglo VI a. C., fábula). La corredora rápida se adelanta, se para a descansar y pierde, mientras que la lenta mantiene un ritmo uniforme todo el camino y gana: lo que importa es el ritmo respecto a la meta, no la velocidad. La UI te mide frente a ese ritmo uniforme del inicio a la fecha límite, te dice el ritmo constante necesario a partir de hoy, y señala con suavidad cuando el progreso llegó a rachas seguidas de un parón.
API
Attributes
| Name | Tipo | Default | Description |
|---|---|---|---|
target | number | The goal, in the same unit as the entries (e.g. 4000 for $4,000, 1500 words). Required. | |
deadline | date | Last day of the plan (inclusive). Accepts YYYY-MM-DD, any Date.parse string, "today" or a date relative to today: "+37d", "+6w", "+3m". Required. | |
start | date | First day of the plan, same formats. Defaults to the first entry’s date (or today). The steady line starts here. | |
start-value | number | 0 | Amount already there before any entry (e.g. money already saved). With entry-type="total", only used when no reading exists on or before the start. |
entry-type | amount | total | amount | amount: each entry adds to the total (deposits, pages read, deals closed). total: each entry is a reading of the running total (an account balance, a fundraiser page); several readings on one day keep the last. |
today | date | Pins “today” (server-rendered pages, reports as of a date, tests). Default: the user’s local date, refreshed at midnight while the page is open. | |
working-days | boolean | Counts Monday to Friday only (minus the holidays property): the steady line is flat on days off, gaps read “3 workdays behind” and the pace is per workday. | |
variant | inline | card | chart | card | inline: status chip + one sentence, for lists and table rows. card: the needed pace, a rail comparing goal done with today’s steady mark, recent pace and notes. chart: the card beside a chart with legend and a data table. |
what-if | fixed | extend | Adds the what-if slider (card and chart variants). fixed keeps the deadline and reports what you would have by then; extend lets the date move and reports when you would finish. Users can switch between the two with the built-in toggle, which updates this attribute. | |
per | day | week | month | day | Period in which rates are written (“$10,756/week”). With working-days, a week is 5 workdays and a month 21.75. |
label | string | Name of the goal, shown in the header and used as the accessible name of the group and in announcements. | |
format | number | currency | number | How amounts are written (Intl.NumberFormat). |
currency | ISO 4217 code | USD | Currency for format="currency". |
unit | string | Suffix for plain numbers (“words”, “km”, “points”); the headline reads “42 words/day”. | |
locale | BCP 47 tag | en-US | Locale for numbers and dates. |
decimals | number | Fixed number of decimals (default: automatic, fewer for large values). Paces are always rounded up at that precision. | |
tolerance | number (days) | 1 | Gap, in days of steady progress, under which you are “On pace” rather than ahead or behind. |
strain | number | 1.5 | When behind and the pace needed from today exceeds this multiple of the original steady rate, the tier becomes far-behind (stronger chip, same kind wording). |
recent-days | number | 14 | Window of the recent pace used for the projection; the card lists the last 7 days and this window. If nothing is logged today yet, the window ends yesterday so a morning never counts against you. |
data-tier / data-burst / data-whatif-touched / data-variant | set by the component | Current tier (not-started | ahead | on-pace | behind | far-behind | done | missed), present when a burst-then-stall pattern is detected, present once the what-if slider moved, resolved variant. |
Properties
| Name | Tipo | Description |
|---|---|---|
entries | Array<[date, amount, note?] | { date, amount, note? }> | Dated entries (any date format accepted by the attributes, including relative ones). Can also come from a child <script type="application/json" data-entries>. Reading returns [{ date: "YYYY-MM-DD", amount, note }]. |
holidays | Array<date> | Days skipped in working-days mode (also from <script type="application/json" data-holidays>). |
pace | object | null | Read-only snapshot, the same object as mv-pace-change’s detail: { tier, label, statement, current, target, remaining, start, deadline, today, per, steadyRate, steadyToday, gap: { units, days, workingDays }, required, daysLeft, recent: [{ days, from, to, pace, ratio, verdict }], projection: { pace, finish, daysVsDeadline }, reachedOn, burst: { days, share } | null }. Dates are YYYY-MM-DD, rates are per the per period. |
status | string | null | Current tier (read-only). |
whatIfPace | number | null | What-if amount per period. Set it to drive the slider from code; null follows the recent pace again. |
formatter | (value, { signed, rate }) => string | Replaces format / currency / unit / decimals for every amount. |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement, with {placeholders} (e.g. { behind: "{days} to catch up", day: "day", days: "days" }). English defaults; see the STRINGS table at the top of steady-pace.js for all keys. |
target / start / deadline / today / startValue / entryType / workingDays / variant / whatIf / per / … | reflected | Every attribute has a matching camelCase property. |
Methods
| Name | Description |
|---|---|
add(amount, date = "today", note?) | Logs an entry (a new total with entry-type="total") and recomputes. Returns the new pace snapshot. |
refresh() | Recomputes, e.g. after the day changed with a pinned today removed. |
Events
| Name | Description |
|---|---|
mv-pace-change | The status tier changed; also fired once after the first computation with previous: null. detail: the pace snapshot plus { previous }. |
mv-whatif | The what-if slider moved, its rule was switched, or whatIfPace was set. detail: { mode: "fixed" | "extend", pace, per, finish (YYYY-MM-DD | null), daysVsDeadline, amountAtDeadline, shortfall, statement }. |
Content structure
| Name | Description |
|---|---|
script[data-entries] / script[data-holidays] | Optional JSON children for plain-HTML use; the component’s own DOM is appended after them. |
CSS classes
| Name | Description |
|---|---|
mv-steady-pace-root[data-variant] / -head / -label / -chip[data-tier] | Frame, header, goal label and status chip (icon + words). |
mv-steady-pace-sentence | The one-line sentence of the inline variant. |
mv-steady-pace-need / -need-value / -need-per / -need-text | Headline: the pace needed from today, its period and the sentence under it. |
mv-steady-pace-rail / -rail-fill / -rail-gap / -rail-mark / -gap | Rail: goal done (fill), today’s steady mark and the hatched gap between them (ahead: solid stripes, behind: outlined stripes); the gap sentence below. |
mv-steady-pace-recent / -recent-row[data-verdict] / -meter / -verdict / -projection | Recent pace rows with a meter and a notch at the needed pace, the verdict word, then the projection sentence. |
mv-steady-pace-note | The burst-then-stall note. |
mv-steady-pace-chart / -plot / -actual / -steady / -needed / -recent-line / -whatif-line / -gap-area / -dot[data-kind] / -finish-label / -legend / -data / -table | Chart parts, markers, legend and the data table disclosure. |
mv-steady-pace-whatif / -modes / -mode / -whatif-input / -whatif-result | What-if group: rule toggle (native radios), native range input (class mv-slider) and result sentence. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-steady-pace-actual | var(--mv-accent) | Actual progress: rail fill, actual line, recent-pace dots, meters. |
--mv-steady-pace-steady | var(--mv-fg-muted) | Steady line color. |
--mv-steady-pace-ahead | var(--mv-success) | Ahead and done tiers, the ahead part of the rail, “Enough” verdicts. |
--mv-steady-pace-behind | var(--mv-warning) | Behind tiers and the behind part of the rail. There is deliberately no red. |
--mv-steady-pace-on-pace | var(--mv-accent) | On-pace chip. |
--mv-steady-pace-whatif | var(--mv-fg) | What-if line, marker and label. |
--mv-steady-pace-chart-height | 13.5rem | Height of the plot area. |
--mv-steady-pace-padding | var(--mv-space-5) | Inner padding of the card. |
Accessibility
The element is a group named by its label. Every status is written in words next to an icon: the chip says “4 days behind”, “On pace” or “Goal reached”, the gap sentence says “$194 behind the steady line”, recent rows end with “Enough”, “Nearly there” or “Less than needed”, and the rail, meters and chart are aria-hidden because the same facts are in the text; colors only reinforce it, and the rail’s ahead and behind parts also differ by pattern (solid versus outlined stripes). The chart’s lines differ by dash pattern and weight, and its full equivalent is a real table (date, added, total, steady line, difference) behind a native <details> disclosure that everyone can open. A single polite live region speaks only when the tier changes (“Emergency fund: On pace. $49/day to reach $4,000 by Nov 1.”), never on the first render, a slider move or a routine update. The what-if uses native controls: a radio group for the rule (keep the deadline or move the date) and <input type="range"> with a visible label, an aria-valuetext with the unit and period, and aria-describedby pointing at the result sentence, so each step is read with its consequence. The burst note and every behind state are phrased as information, never blame, and no red is used. Reduced motion (OS setting or data-motion="reduce"): no chart draw-in, rail and meter changes jump instead of sliding. Forced colors: fills use Highlight, markers CanvasText, the chip keeps a visible outline.