Exclusivebeta
As Of — <mv-as-of>
Point-in-time (“as of”) control for data apps that can show the past, so historical figures are never mistaken for current ones. Three rows stay visible at all times: Viewing (the chosen moment, or live data, with a date or date-time field, ±1 hour/day/week/month/quarter/year steps that keep end-of-month sticky, and a Jump to menu of presets like “End of last quarter” plus recent destinations), Now (a live clock with the time zone) and Left from (the moment you were at before the last jump, one click to go back, so comparing two dates is a toggle). While viewing the past, a calm banner (“You’re viewing data as of Jun 30, 2026 — Return to now”) and optional frames around your data regions say so in words, and every [data-as-of-live-only] control in scope (edit, adjust, save buttons) becomes inert until you return. Each jump goes through a cancelable mv-travel whose waitUntil(promise) shows a pending state while you refetch, and rolls back with an announcement if the fetch fails; rapid steps settle into a single travel, the future is clamped to live and anything before min to the earliest available data, the moment can live in a URL parameter (with back/forward support) and recent destinations can persist in localStorage.
| Category | Navigation |
|---|---|
| Type | Web Component (<mv-as-of>) |
| Status | beta |
| Also installs | button, input, select |
| Keywords | exclusive, culture, point-in-time, as-of, time-travel, history, snapshot, audit, versioning, dashboard, report, financial, period-end, read-only, url-state, temporal |
When to use
- A dashboard or report can be recomputed as of a past date and viewers must never confuse it with current figures
- An audit or history view shows a configuration, permissions or inventory exactly as it stood at a given moment
- Financial statements are reviewed at period ends (month, quarter, year) with quick back-and-forth between two dates
- Editing must be blocked while someone is looking at a historical snapshot, and resume as soon as they return to now
Avoid when
- The user picks a date as form input (due date, booking, birthday), not a moment to view the whole app at → use Date Picker instead
- The job is to display one moment or deadline clearly across time zones → use Dateline instead
- The need is to undo or compare edits of a single form or document rather than view data at a past moment → use Rewind instead
Install
node scripts/add.mjs as-of --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["as-of"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dismiss.js, core/dom.js, core/element.js, core/focus.js, core/observe.js, core/position.js, components/as-of/as-of.js, components/as-of/as-of.css, components/button/button.css, components/input/input.css, components/select/select.css.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<div id="ao-demo" style="width:min(100%,66rem);margin-inline:auto">
<style>
#ao-demo { display:grid; grid-template-columns:minmax(0,1fr); gap:2.5rem; align-content:start }
#ao-demo .ao-main { display:grid; grid-template-columns:minmax(0,1fr) 24rem; gap:1.5rem; align-items:start }
#ao-demo .ao-report { display:grid; gap:1rem; padding:1.25rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs) }
#ao-demo .ao-top { display:flex; align-items:flex-start; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap }
#ao-demo .ao-top h3 { margin:0; font-size:1.0625rem; letter-spacing:-.01em }
#ao-demo .ao-top p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#ao-demo .ao-actions { display:flex; gap:.5rem; flex-wrap:wrap }
#ao-demo .ao-kpis { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:.75rem; margin:0 }
#ao-demo .ao-kpi { display:grid; gap:.25rem; padding:.75rem .875rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-bg-subtle) }
#ao-demo .ao-kpi dt { color:var(--mv-fg-muted); font-size:.75rem }
#ao-demo .ao-kpi dd { margin:0; font-size:1.25rem; font-weight:650; letter-spacing:-.02em; font-variant-numeric:tabular-nums }
#ao-demo .ao-kpi small { display:block; color:var(--mv-fg-muted); font-size:.6875rem; font-weight:500; letter-spacing:0 }
#ao-demo table { width:100%; border-collapse:collapse; font-size:.8125rem }
#ao-demo caption { text-align:start; padding:0 0 .5rem; color:var(--mv-fg-muted); font-size:.75rem; font-weight:500 }
#ao-demo th, #ao-demo td { padding:.5rem .625rem; border-bottom:1px solid var(--mv-border); text-align:start; white-space:nowrap }
#ao-demo th { color:var(--mv-fg-muted); font-size:.6875rem; font-weight:600; letter-spacing:.04em; text-transform:uppercase }
#ao-demo td.num, #ao-demo th.num { text-align:end; font-variant-numeric:tabular-nums }
#ao-demo tr:last-child td { border-bottom:0 }
#ao-demo .ao-side { display:grid; gap:.875rem }
#ao-demo .ao-panel { display:grid; gap:.625rem; padding:.875rem 1rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-bg-subtle); font-size:.8125rem }
#ao-demo .ao-log { min-height:1.25rem; margin:0; color:var(--mv-fg-subtle); font:.75rem/1.45 var(--mv-font-mono); overflow-wrap:anywhere }
#ao-demo .ao-h { margin:0 0 .75rem; font-size:.8125rem; font-weight:600; color:var(--mv-fg-muted) }
#ao-demo .ao-flags { padding:1rem 1.25rem .75rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); margin-top:1.25rem }
#ao-demo .ao-flag-name { font-family:var(--mv-font-mono); font-size:.75rem }
@media (max-width:62rem) { #ao-demo .ao-main { grid-template-columns:minmax(0,1fr) } }
@media (max-width:40rem) { #ao-demo .ao-kpis { grid-template-columns:repeat(2,minmax(0,1fr)) } #ao-demo .ao-hide-sm { display:none } #ao-demo td:first-child { white-space:normal } #ao-demo .ao-report, #ao-demo .ao-flags { padding-inline:.875rem } #ao-demo th, #ao-demo td { padding-inline:.375rem } }
</style>
<!-- 1. Inventory report with the stacked control beside it -->
<div class="ao-main" id="ao-app">
<section class="ao-report" id="ao-report" aria-labelledby="ao-report-title">
<div class="ao-top">
<div>
<h3 id="ao-report-title">Inventory & revenue</h3>
<p>Northwind Supply · all warehouses</p>
</div>
<div class="ao-actions">
<button type="button" class="mv-button" data-variant="outline" data-size="sm" data-as-of-live-only>Adjust stock</button>
<button type="button" class="mv-button" data-size="sm" data-as-of-live-only>Edit forecast</button>
</div>
</div>
<dl class="ao-kpis" id="ao-kpis"></dl>
<table>
<caption id="ao-caption">Top SKUs by value on hand</caption>
<thead><tr><th scope="col">SKU</th><th scope="col" class="ao-hide-sm">Warehouse</th><th scope="col" class="num">On hand</th><th scope="col" class="num">Value</th><th scope="col">Status</th></tr></thead>
<tbody id="ao-rows"></tbody>
</table>
</section>
<div class="ao-side">
<mv-as-of id="ao-main" param="asof" frame="#ao-report" scope="#ao-app" min="2024-01-01" storage-key="ao-demo-recent"></mv-as-of>
<div class="ao-panel">
<label class="mv-choice" data-control="end">
<input type="checkbox" role="switch" class="mv-switch" id="ao-fail">
<span class="mv-choice-text"><span class="mv-choice-title">Make the next fetch fail</span><span class="mv-choice-description">The jump is rolled back and announced</span></span>
</label>
<p class="ao-log" id="ao-log" aria-live="polite">Loading…</p>
</div>
</div>
</div>
<!-- 2. Bar layout, minute precision: a production config audited at a past moment -->
<div>
<p class="ao-h">Feature flags · production (bar layout, minute precision)</p>
<div id="ao-flags-app">
<mv-as-of id="ao-bar" layout="bar" precision="minute" steps="hour, day, week" unit="hour" presets="7d, 30d, last-month" frame="#ao-flags" scope="#ao-flags-app" label="Config point in time"></mv-as-of>
<div class="ao-flags" id="ao-flags">
<table>
<caption id="ao-flag-caption">Flags</caption>
<thead><tr><th scope="col">Flag</th><th scope="col">State</th><th scope="col" class="num">Rollout</th><th scope="col" class="ao-hide-sm">Changed by</th><th scope="col"><span class="mv-sr-only">Actions</span></th></tr></thead>
<tbody id="ao-flag-rows"></tbody>
</table>
</div>
</div>
</div>
<script type="module">
const main = document.getElementById("ao-main");
const bar = document.getElementById("ao-bar");
const log = document.getElementById("ao-log");
const fail = document.getElementById("ao-fail");
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
const int = new Intl.NumberFormat("en-US");
const short = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric" });
// Deterministic "database": the same moment always returns the same figures.
const seed = (str) => { let h = 2166136261; for (const c of str) { h ^= c.charCodeAt(0); h = Math.imul(h, 16777619); } return () => ((h = Math.imul(h ^ (h >>> 15), 2246822507) ^ Math.imul(h ^ (h >>> 13), 3266489909)) >>> 0) / 4294967296; };
const cell = (text, cls) => { const td = document.createElement("td"); if (cls) td.className = cls; td.append(text); return td; };
const badge = (text, variant) => { const b = document.createElement("span"); b.className = "mv-badge"; b.dataset.variant = variant; b.dataset.size = "sm"; b.textContent = text; return b; };
const SKUS = [
["Ceramic pour-over set", "Rotterdam"], ["Linen apron, charcoal", "Reno"], ["Cast-iron skillet 26 cm", "Rotterdam"],
["Oak cutting board", "Monterrey"], ["Stoneware mug, set of 4", "Reno"],
];
function renderReport(date) {
const key = date ? date.toISOString().slice(0, 10) : "live";
const r = seed(key);
const f = date ? 0.82 + r() * 0.16 : 1;
const kpis = [
["Revenue, trailing 30 days", usd.format(Math.round(412_800 * f)), date ? `as of ${short.format(date)}` : "live"],
["Units on hand", int.format(Math.round(18_420 * (0.9 + r() * 0.2))), "5 warehouses"],
["Open orders", int.format(Math.round(236 * (0.7 + r() * 0.5))), "awaiting shipment"],
["Gross margin", `${(41.2 + (r() - 0.5) * 6).toFixed(1)}%`, "trailing 30 days"],
];
document.getElementById("ao-kpis").replaceChildren(...kpis.map(([k, v, s]) => {
const div = document.createElement("div"); div.className = "ao-kpi";
const dt = document.createElement("dt"); dt.textContent = k;
const dd = document.createElement("dd"); dd.textContent = v;
const sm = document.createElement("small"); sm.textContent = s; dd.append(sm);
div.append(dt, dd); return div;
}));
document.getElementById("ao-caption").textContent = date ? `Top SKUs by value on hand, as of ${short.format(date)}` : "Top SKUs by value on hand, live";
document.getElementById("ao-rows").replaceChildren(...SKUS.map(([name, wh], i) => {
const qty = Math.round((1400 - i * 210) * (0.25 + r() * 0.9));
const value = qty * (18 + (5 - i) * 7);
const status = qty < 320 ? ["Reorder", "danger"] : qty < 620 ? ["Low", "warning"] : ["In stock", "success"];
const tr = document.createElement("tr");
tr.append(cell(name), cell(wh, "ao-hide-sm"), cell(int.format(qty), "num"), cell(usd.format(value), "num"), cell(badge(...status)));
return tr;
}));
}
const FLAGS = ["checkout.one-page", "search.semantic-ranking", "billing.annual-discount", "mobile.dark-launch"];
const PEOPLE = ["Aiko Tanaka", "Lucas Moreau", "Priya Raman", "Mateo Silva"];
function renderFlags(date) {
const key = date ? `${date.toISOString().slice(0, 13)}` : "live";
const r = seed(key);
document.getElementById("ao-flag-caption").textContent = date ? `Flags as of ${date.toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" })}` : "Flags, live";
document.getElementById("ao-flag-rows").replaceChildren(...FLAGS.map((flag, i) => {
const on = (i + Math.floor(r() * 3)) % 3 !== 0;
const rollout = on ? [5, 25, 50, 100][Math.floor(r() * 4)] : 0;
const tr = document.createElement("tr");
const name = document.createElement("span"); name.className = "ao-flag-name"; name.textContent = flag;
const edit = document.createElement("button");
edit.type = "button"; edit.className = "mv-button"; edit.dataset.variant = "ghost"; edit.dataset.size = "sm";
edit.dataset.asOfLiveOnly = ""; edit.textContent = "Edit"; edit.setAttribute("aria-label", `Edit ${flag}`);
tr.append(cell(name), cell(badge(on ? "On" : "Off", on ? "success" : "secondary")), cell(`${rollout}%`, "num"), cell(PEOPLE[(i + Math.floor(r() * 4)) % 4], "ao-hide-sm"), cell(edit, "num"));
return tr;
}));
}
// A fake API: 450 ms round trip, optionally failing once.
const fetchAsOf = (reason) => new Promise((resolve, reject) => {
if (reason === "api") return resolve();
setTimeout(() => {
if (fail.checked) { fail.checked = false; reject(new Error("504 Gateway Timeout")); } else resolve();
}, 450);
});
const label = (d) => (d ? short.format(d) : "live");
main.addEventListener("mv-travel", (e) => {
const { from, to, reason } = e.detail;
log.textContent = `GET /reports/inventory?asof=${e.detail.toValue ?? "now"} · ${reason}`;
e.detail.waitUntil(fetchAsOf(reason).then(() => renderReport(to)));
});
main.addEventListener("mv-arrive", (e) => { log.textContent = `mv-arrive · ${label(e.detail.from)} → ${label(e.detail.to)}`; });
main.addEventListener("mv-travel-error", (e) => { log.textContent = `Fetch failed (${e.detail.error.message}) · rolled back to ${label(e.detail.from)}`; renderReport(e.detail.from); });
bar.addEventListener("mv-travel", (e) => { e.detail.waitUntil(fetchAsOf(e.detail.reason).then(() => renderFlags(e.detail.to))); });
// Start in the past so all three rows are filled: live → end of last month → end of last quarter.
const now = new Date();
const lastQuarter = new Date(now.getFullYear(), Math.floor(now.getMonth() / 3) * 3, 0);
let first = new Date(now.getFullYear(), now.getMonth(), 0);
if (+first === +lastQuarter) first = new Date(now.getFullYear(), now.getMonth() - 1, 0);
renderReport(main.date);
renderFlags(null);
if (main.live) {
main.travel(first).then(() => main.travel(lastQuarter));
}
const twoDaysAgo = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 2, 16, 45);
bar.travel(twoDaysAgo);
</script>
</div>Cultural reference
Back to the Future — Robert Zemeckis (1985, film). The time machine's dashboard always shows three readouts, destination time, present time and last time departed, so the traveler never loses track of when they are relative to where they came from. In the UI, a data app shows the same three rows (the moment being viewed, the live present and the moment left before the last jump, one click back), with a calm banner, framed data and locked edits whenever you are in the past.
API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
value | string ("2026-06-30" | "2026-06-30T17:30" | ISO with offset) | The viewed moment as a local wall time; absent = live. Reflected after every travel. Setting it from outside applies silently (no mv-travel, Left from unchanged): call travel() to go through the event flow. Future moments resolve to live, earlier ones than min are clamped to min. | |
precision | day | minute | day | day: a date field, and a value means the end of that day (close of business); picking today means live. minute: a date-time field and values to the minute; anything within the last minute means live. |
min | date or date-time | Earliest moment with data (retention start, company founding). Stepping and typing clamp to it and the clamp is announced; the back step is disabled there. | |
steps | comma list of hour | day | week | month | quarter | year | day, week, month | Units offered in the step-size select (hidden when only one). Month, quarter and year steps keep end of month sticky: Mar 31 → Apr 30 → May 31. |
unit | one of steps | day (or the first step) | Initially selected step unit. |
presets | comma list of yesterday | last-week | last-month | last-quarter | last-year | 7d | 30d | 90d | 1y | yesterday, last-week, last-month, last-quarter, last-year | Built-in presets of the Jump to menu, resolved against the current date when the menu opens (weeks follow the locale's first day). The presets property also accepts custom presets. |
param | string | URL search parameter that holds the moment (e.g. asof). Read on first connection (a deep link wins over value), written after every travel, removed when live; popstate travels with reason "history". | |
param-mode | replace | push | replace | replace keeps one history entry; push adds an entry per travel so Back/Forward walk through visited moments. |
frame | CSS selector | Data regions to mark while in the past: data-as-of-past (tinted outline + an “As of …” tag drawn with ::before), aria-busy during a pending travel and aria-describedby pointing to the banner text. They get position: relative (zero specificity, your styles win). | |
scope | CSS selector | document | Root in which [data-as-of-live-only] elements are governed, including ones added later. Set it when a page has several instances. |
layout | stack | bar | stack | stack: three stacked rows (sidebar, panel). bar: three columns for the top of a dashboard (from 46rem of container width, stacked below), banner under the bar. |
locale | BCP 47 | en-US | Date, time, relative-time formatting and first day of the week. |
label | string | Point in time | Accessible name of the three-row group. |
history-size | number | 5 | How many recent destinations the Jump to menu keeps (0 disables them). |
storage-key | string | localStorage key to persist recent destinations across visits (failures are ignored). | |
settle | number (ms) | 400 | Quiet time after the last step click before a single travel is emitted. Typed dates settle after 900 ms, or at once on Enter or blur; Escape discards a typed draft. |
data-state | live | past | Set by the component. data-pending is present while waitUntil promises are pending, data-drafting while steps settle. | |
data-as-of-live-only | boolean (on your elements) | Elements in scope with this attribute become inert (plus data-as-of-locked for styling, dimmed by default) while viewing the past; their previous inert state is restored when you return or when the component is removed. |
Properties
| Name | Type | Description |
|---|---|---|
date | Date | null | The viewed moment (end of day with precision="day"), null when live (read-only). |
live | boolean | True while viewing live data (read-only). |
departed | { value, date, live, at } | null | Where you were before the last jump (the Left from row); at is when you left (read-only). |
recent | string[] | Recent destinations as normalized values, newest first. Settable (e.g. from your server). |
pending | boolean | True while the last travel waits for its waitUntil promises (read-only). |
presets | string | Array<{ label, date: Date | string | (now: Date) => Date }> | A string mirrors the attribute; an array replaces the built-in presets (e.g. fiscal periods, release dates, “Before the migration”). |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement (viewing, now, departed, live, past, liveTitle, liveSub, endOfDay, bannerText ({date}), bannerNote, bannerLocked, returnToNow, frameLabel ({date}), jump, presets, recent, goBack, goBackTo ({date}), stepBack / stepForward ({unit}), unitDay…, presetLastQuarter…, announceTravel ({date}), announceLive, announceMin, announceError ({date}, {current})…). English defaults. |
Methods
| Name | Description |
|---|---|
travel(target, { reason? }) | Travel to a Date, a value string, or null / "now" for live. Emits mv-travel (cancelable), applies the moment, waits for waitUntil promises, then emits mv-arrive. Returns Promise<boolean> (false if cancelled, unchanged, superseded or failed). |
returnToNow() | Travel to live data (reason "return"). |
goBack() | Travel to the Left from moment (reason "back"); calling it twice toggles between two moments. |
step(n = -1, unit?) | Step n units from the viewed moment (live counts as now); consecutive steps settle into one travel (reason "step"). |
Events
| Name | Description |
|---|---|
mv-travel | Cancelable, before a jump. detail: { from, to (Date | null = live), fromValue, toValue, live, reason: "step" | "input" | "preset" | "recent" | "back" | "return" | "history" | "api", waitUntil(promise) }. Refetch here and pass the promise to waitUntil: frames get aria-busy and a progress line runs; a rejection rolls the moment, Left from and recent back. preventDefault() refuses the jump. |
mv-arrive | The jump is complete (after waitUntil promises resolved). detail: { from, to, fromValue, toValue, live, reason }. The arrival is announced politely. |
mv-travel-error | A waitUntil promise rejected and the jump was rolled back. detail: { from, to, fromValue, toValue, reason, error }. |
CSS classes
| Name | Description |
|---|---|
mv-as-of-banner | Past-mode region: -banner-icon, -banner-text (-banner-lead with <time class="mv-as-of-banner-time">, -banner-note) and .mv-as-of-return. |
mv-as-of-console | The labeled group of three .mv-as-of-row[data-row="viewing | now | departed"], each with -head (.mv-as-of-label), -moment (<time class="mv-as-of-time"> + -sub) and -action. The viewing row gets data-past. |
mv-as-of-controls | Viewing row controls: -stepper (.mv-as-of-step[data-dir] + .mv-as-of-input), .mv-as-of-unit, .mv-as-of-jump; .mv-as-of-state is the Live / Past badge (data-tone), .mv-as-of-progress the pending line. |
mv-as-of-menu | Jump to menu (Popover API top layer): -menu-group, -menu-heading, .mv-as-of-option (aria-current="date" on the viewed moment) with -option-check, -option-label, -option-sub. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-as-of-tint | var(--mv-warning) | Past-mode color: banner, Viewing row edge, Past badge, frame outline and tag (also read by frames outside the component). |
--mv-as-of-live | var(--mv-success) | Live color: Live badge and the breathing Now marker. |
--mv-as-of-label-width | 6.75rem | Width of the row label column in the stack layout. |
Accessibility
The three rows are a labeled group (label, “Point in time” by default) of three nested groups named by their visible labels (Viewing, Now, Left from), each exposing its moment in a real <time datetime> element, so a screen reader reads “Viewing, group, Tue, Jun 30, 2026, End of day · 3 months ago”. Every control is a native element reachable with Tab: back / forward step buttons named “Back 1 month” / “Forward 1 month” (aria-disabled rather than disabled at the limits, so focus is never lost), a labeled date or date-time field (Enter commits, Escape discards the draft), a labeled step-size select, the Jump to button (aria-expanded, aria-controls) and “Go back”, whose accessible name includes the destination (“Go back to Monday, August 31, 2026”). The Jump to menu opens with focus on the current moment, moves with arrow keys, Home and End (roving tabindex), marks the viewed moment with aria-current="date" and a check icon, and closes on Escape or outside click, returning focus to its button. Each arrival is announced once in a polite live region (“Now viewing data as of Tuesday, June 30, 2026.” / “Back to live data.”), as are clamps to the earliest data and failed fetches with their rollback; the Now clock updates silently. The past is never signaled by color alone: the banner is a named region (“Historical view”) with words, a <time> and a Return to now button, the badge says Live or Past with a distinct icon, and framed regions get an “As of …” tag plus aria-describedby pointing to the banner text and aria-busy while data reloads. Live-only controls become inert (out of the tab order and the accessibility tree) and the banner says editing is paused; if a control that had focus disappears, focus moves to the date field. Reduced motion (OS or data-motion="reduce"): the Now marker stops breathing and the loading line becomes static. Forced colors: borders use CanvasText and the past edge, frames and progress use Highlight.