Exclusivebeta

Steady Pace <mv-steady-pace>

Goal pacing that tells you the steady rate needed from today, not just a percentage: savings goals, sales quotas, reading or learning goals, fundraising, sprint scope, training plans. Give it a target, a start, a deadline and dated entries (amounts, or running totals with entry-type="total"), and it compares where you are with the even “steady line” from start to deadline: ahead or behind in units and in days (“$194 behind the steady line”, “4 days behind”), the pace needed from today, rounded up so following it always finishes on time (“$54/day to reach $4,000 by Nov 1”), whether your last 7 and 14 days are enough (small meters with a notch at the needed pace and a word verdict), and where your recent pace lands (“you’d finish around Nov 15, 14 days after the deadline”). When most of the progress came on one or two days followed by a quiet week, a kind note says so and gives the small daily amount that keeps things steady, never a warning. The chart variant draws cumulative actual progress as a staircase, the steady line, the line needed from today and the recent-pace projection with its finish date labelled, with a hover readout and a real data table behind a disclosure. A what-if slider tests another daily amount under two rules: keep the deadline (what you would have by then) or move the date (when you would finish). Workdays-only counting with holidays, per day, week or month display, relative dates (“+37d”, “-2w”) and a midnight refresh. Three variants: an inline sentence, a card and a full chart.

CategoryData display
TypeWeb Component (<mv-steady-pace>)
Statusbeta
KitNumbers that explain themselves
Also installsslider
Keywordsexclusive, 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

Install

node scripts/add.mjs steady-pace --out ./src/marvelous

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["steady-pace"], 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/steady-pace/steady-pace.js, components/steady-pace/steady-pace.css, components/slider/slider.js, components/slider/slider.css.

Usage

Quick start, the smallest working markup:

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

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

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

Cultural reference

The Tortoise and the Hare, Aesop (c. 6th century BC, fable). The fast runner sprints ahead, stops to rest and loses, while the slow one keeps an even pace all the way and wins: what matters is pace relative to the finish line, not speed. The UI measures you against that even pace from start to deadline, tells you the steady rate needed from today, and gently points out when progress came in bursts followed by a stall.

API

Attributes

NameTypeDefaultDescription
targetnumberThe goal, in the same unit as the entries (e.g. 4000 for $4,000, 1500 words). Required.
deadlinedateLast day of the plan (inclusive). Accepts YYYY-MM-DD, any Date.parse string, "today" or a date relative to today: "+37d", "+6w", "+3m". Required.
startdateFirst day of the plan, same formats. Defaults to the first entry’s date (or today). The steady line starts here.
start-valuenumber0Amount 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-typeamount | totalamountamount: 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.
todaydatePins “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-daysbooleanCounts 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.
variantinline | card | chartcardinline: 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-iffixed | extendAdds 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.
perday | week | monthdayPeriod in which rates are written (“$10,756/week”). With working-days, a week is 5 workdays and a month 21.75.
labelstringName of the goal, shown in the header and used as the accessible name of the group and in announcements.
formatnumber | currencynumberHow amounts are written (Intl.NumberFormat).
currencyISO 4217 codeUSDCurrency for format="currency".
unitstringSuffix for plain numbers (“words”, “km”, “points”); the headline reads “42 words/day”.
localeBCP 47 tagen-USLocale for numbers and dates.
decimalsnumberFixed number of decimals (default: automatic, fewer for large values). Paces are always rounded up at that precision.
tolerancenumber (days)1Gap, in days of steady progress, under which you are “On pace” rather than ahead or behind.
strainnumber1.5When 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-daysnumber14Window 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-variantset by the componentCurrent 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

NameTypeDescription
entriesArray<[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 }].
holidaysArray<date>Days skipped in working-days mode (also from <script type="application/json" data-holidays>).
paceobject | nullRead-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.
statusstring | nullCurrent tier (read-only).
whatIfPacenumber | nullWhat-if amount per period. Set it to drive the slider from code; null follows the recent pace again.
formatter(value, { signed, rate }) => stringReplaces format / currency / unit / decimals for every amount.
stringsPartial<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 / …reflectedEvery attribute has a matching camelCase property.

Methods

NameDescription
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

NameDescription
mv-pace-changeThe status tier changed; also fired once after the first computation with previous: null. detail: the pace snapshot plus { previous }.
mv-whatifThe 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

NameDescription
script[data-entries] / script[data-holidays]Optional JSON children for plain-HTML use; the component’s own DOM is appended after them.

CSS classes

NameDescription
mv-steady-pace-root[data-variant] / -head / -label / -chip[data-tier]Frame, header, goal label and status chip (icon + words).
mv-steady-pace-sentenceThe one-line sentence of the inline variant.
mv-steady-pace-need / -need-value / -need-per / -need-textHeadline: the pace needed from today, its period and the sentence under it.
mv-steady-pace-rail / -rail-fill / -rail-gap / -rail-mark / -gapRail: 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 / -projectionRecent pace rows with a meter and a notch at the needed pace, the verdict word, then the projection sentence.
mv-steady-pace-noteThe 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 / -tableChart parts, markers, legend and the data table disclosure.
mv-steady-pace-whatif / -modes / -mode / -whatif-input / -whatif-resultWhat-if group: rule toggle (native radios), native range input (class mv-slider) and result sentence.

CSS variables

NameDefaultDescription
--mv-steady-pace-actualvar(--mv-accent)Actual progress: rail fill, actual line, recent-pace dots, meters.
--mv-steady-pace-steadyvar(--mv-fg-muted)Steady line color.
--mv-steady-pace-aheadvar(--mv-success)Ahead and done tiers, the ahead part of the rail, “Enough” verdicts.
--mv-steady-pace-behindvar(--mv-warning)Behind tiers and the behind part of the rail. There is deliberately no red.
--mv-steady-pace-on-pacevar(--mv-accent)On-pace chip.
--mv-steady-pace-whatifvar(--mv-fg)What-if line, marker and label.
--mv-steady-pace-chart-height13.5remHeight of the plot area.
--mv-steady-pace-paddingvar(--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.