Exclusivebeta
Deja Vu — <mv-deja-vu>
Flags, instantly and in place, the changes the system made behind the user's back — never the ones the user caused. Wraps a live region (feed, dashboard, table, server-synced form); when a unit (row, card, fieldset… or anything marked data-deja-vu-unit) changes and the user has not pointed, typed or pressed a key inside it for the last grace ms, it does a double take: a ghost of the previous value crosses the new one twice while the new value repeats its entrance, two identical pulses in ~600 ms. A small “Changed · 12s ago” tab (“New” for inserted rows) and a faint outline then stay on the unit, with a relative time that updates cheaply, until the unit is hovered, focused or has been on screen for linger ms. Changes are batched per frame, a unit never replays its double take more than once per cooldown, and the unacknowledged list is exposed as changes (acknowledge() clears it); an optional polite announcement sums them up (“3 values changed: Revenue today, Status of Order #4821, Conversion.”).
| Category | Feedback |
|---|---|
| Type | Web Component (<mv-deja-vu>) |
| Status | beta |
| Keywords | exclusive, culture, feedback, change-detection, live-data, realtime, sync, dashboard, table, mutation-observer, highlight, diff, notification, awareness, collaboration |
When to use
- A dashboard or table is updated by the server while people read it, and silent value changes must not go unnoticed
- A form is synced from another device or collaborator and fields may change under the user's eyes
- A live feed or queue updates items in place and users need to see which ones changed without being interrupted
- Operators must trust that a number they just looked at is still the one on screen
Avoid when
- The user needs a summary of everything that changed while they were away or the tab was hidden → use While Away instead
- The change is an event that deserves its own message (error, completed job, new message) → use Toast instead
- The region re-renders wholesale several times per second (tickers, live charts): mark that part data-deja-vu-ignore
Install
node scripts/add.mjs deja-vu --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["deja-vu"], 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/deja-vu/deja-vu.js, components/deja-vu/deja-vu.css.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<div id="dv-demo" style="width:min(100%,60rem);margin-inline:auto">
<style>
#dv-demo .dv-top { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1rem; flex-wrap:wrap; margin:0 0 1.25rem }
#dv-demo .dv-top h3 { margin:0; font-size:1.125rem; letter-spacing:-.01em }
#dv-demo .dv-top p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#dv-demo .dv-bar { display:flex; align-items:center; gap:.625rem; flex-wrap:wrap }
#dv-demo .dv-count { display:inline-flex; align-items:center; gap:.375rem; color:var(--mv-fg-muted); font-size:.8125rem; font-variant-numeric:tabular-nums }
#dv-demo .dv-count b { color:var(--mv-fg); font-weight:600 }
#dv-demo .dv-live { display:inline-flex; align-items:center; gap:.375rem; color:var(--mv-fg-muted); font-size:.75rem }
#dv-demo .dv-live i { width:.4375rem; height:.4375rem; border-radius:50%; background:var(--mv-success); box-shadow:0 0 0 3px color-mix(in oklab, var(--mv-success) 20%, transparent) }
#dv-demo .dv-kpis { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:.875rem; margin:0 0 .875rem }
#dv-demo .dv-kpi { min-width:0; padding:1rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface) }
#dv-demo .dv-kpi h4 { margin:0; color:var(--mv-fg-muted); font-size:.75rem; font-weight:500 }
#dv-demo .dv-kpi strong { display:block; margin:.375rem 0 .125rem; font-size:1.5rem; font-weight:650; letter-spacing:-.02em; font-variant-numeric:tabular-nums; line-height:1.2 }
#dv-demo .dv-kpi small { color:var(--mv-fg-subtle); font-size:.75rem; font-variant-numeric:tabular-nums }
#dv-demo .dv-main { display:grid; grid-template-columns:minmax(0,1fr) 17rem; gap:.875rem; align-items:start }
#dv-demo .dv-panel { min-width:0; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); overflow:hidden }
#dv-demo .dv-panel > header { display:flex; align-items:center; justify-content:space-between; padding:.875rem 1.125rem; border-bottom:1px solid var(--mv-border) }
#dv-demo .dv-panel h4 { margin:0; font-size:.8125rem; font-weight:600 }
#dv-demo .dv-panel header small { color:var(--mv-fg-subtle); font-size:.75rem }
#dv-demo table { width:100%; border-collapse:collapse; font-size:.8125rem }
#dv-demo th { padding:.5rem 1.125rem; color:var(--mv-fg-muted); font-size:.6875rem; font-weight:600; letter-spacing:.04em; text-transform:uppercase; text-align:left; background:var(--mv-bg-subtle) }
#dv-demo td { padding:.625rem 1.125rem; border-top:1px solid var(--mv-border); white-space:nowrap }
#dv-demo td:first-child { font-weight:600; font-variant-numeric:tabular-nums }
#dv-demo td.dv-num, #dv-demo th.dv-num { text-align:right; font-variant-numeric:tabular-nums }
#dv-demo td.dv-who { color:var(--mv-fg-muted) }
#dv-demo fieldset { margin:0; padding:1rem 1.125rem 1.125rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); display:grid; gap:.625rem }
#dv-demo legend { float:left; width:100%; padding:0; margin:0 0 .25rem; font-size:.8125rem; font-weight:600 }
#dv-demo label.dv-l { display:grid; gap:.375rem; font-size:.75rem; color:var(--mv-fg-muted) }
#dv-demo .dv-note { margin:0; color:var(--mv-fg-muted); font-size:.75rem; line-height:1.45 }
#dv-demo .dv-note b { color:var(--mv-fg); font-weight:600; font-variant-numeric:tabular-nums }
#dv-demo .dv-sync { display:flex; align-items:center; gap:.375rem; color:var(--mv-fg-subtle); font-size:.6875rem }
#dv-demo .dv-hint { margin:.875rem 0 0; color:var(--mv-fg-muted); font-size:.75rem; text-align:center }
@media (max-width:52rem) {
#dv-demo .dv-kpis { grid-template-columns:repeat(2,minmax(0,1fr)) }
#dv-demo .dv-main { grid-template-columns:minmax(0,1fr) }
#dv-demo .dv-panel { overflow-x:auto }
}
</style>
<div class="dv-top">
<div>
<h3>Store operations</h3>
<p>Today · synced with the order service</p>
</div>
<div class="dv-bar">
<span class="dv-live"><i aria-hidden="true"></i>Live</span>
<span class="dv-count" aria-live="polite"><b id="dv-count">0</b> changes flagged</span>
<button class="mv-button" data-variant="ghost" data-size="sm" type="button" id="dv-ack">Acknowledge all</button>
<button class="mv-button" data-variant="outline" data-size="sm" type="button" id="dv-now">Change something now</button>
</div>
</div>
<mv-deja-vu id="dv-dash" announce linger="12000">
<div class="dv-kpis">
<article class="dv-kpi"><h4>Revenue today</h4><strong data-k="revenue">$48,210</strong><small data-k="revenue-note">+8.2% vs. last Tuesday</small></article>
<article class="dv-kpi"><h4>Orders</h4><strong data-k="orders">312</strong><small>avg. basket $154.52</small></article>
<article class="dv-kpi"><h4>Conversion</h4><strong data-k="conversion">3.4%</strong><small>2,140 sessions</small></article>
<article class="dv-kpi"><h4>Refund requests</h4><strong data-k="refunds">4</strong><small>2 awaiting review</small></article>
</div>
<div class="dv-main">
<section class="dv-panel" aria-labelledby="dv-orders-h">
<header><h4 id="dv-orders-h">Recent orders</h4><small>Updated by the order service</small></header>
<table>
<thead><tr><th>Order</th><th>Customer</th><th class="dv-num">Total</th><th>Status</th></tr></thead>
<tbody id="dv-rows">
<tr data-deja-vu-unit="Order #4821"><td>#4821</td><td class="dv-who">Amara Okafor</td><td class="dv-num">$212.40</td><td><span class="mv-badge" data-variant="info" data-shape="pill"><span class="mv-badge-dot"></span><span data-s>Packed</span></span></td></tr>
<tr data-deja-vu-unit="Order #4820"><td>#4820</td><td class="dv-who">Lucas Moreau</td><td class="dv-num">$89.00</td><td><span class="mv-badge" data-variant="warning" data-shape="pill"><span class="mv-badge-dot"></span><span data-s>Processing</span></span></td></tr>
<tr data-deja-vu-unit="Order #4819"><td>#4819</td><td class="dv-who">Mei Tanaka</td><td class="dv-num">$145.90</td><td><span class="mv-badge" data-variant="success" data-shape="pill"><span class="mv-badge-dot"></span><span data-s>Shipped</span></span></td></tr>
<tr data-deja-vu-unit="Order #4818"><td>#4818</td><td class="dv-who">Diego Alvarez</td><td class="dv-num">$64.25</td><td><span class="mv-badge" data-variant="warning" data-shape="pill"><span class="mv-badge-dot"></span><span data-s>Processing</span></span></td></tr>
<tr data-deja-vu-unit="Order #4817"><td>#4817</td><td class="dv-who">Hannah Becker</td><td class="dv-num">$320.00</td><td><span class="mv-badge" data-variant="success" data-shape="pill"><span class="mv-badge-dot"></span><span data-s>Delivered</span></span></td></tr>
</tbody>
</table>
</section>
<fieldset>
<legend>Free-shipping threshold</legend>
<label class="dv-l">Orders above
<input class="mv-input" id="dv-threshold" type="number" inputmode="decimal" min="0" step="5" value="75">
</label>
<p class="dv-note">Applies to <b id="dv-share">64%</b> of today's orders.</p>
<span class="dv-sync">Type here: your own edits are never flagged</span>
</fieldset>
</div>
</mv-deja-vu>
<p class="dv-hint">The order service updates this dashboard every few seconds · each change it makes does a quick double take, then keeps a marker until you hover, focus or look at it for a while</p>
<script type="module">
const dv = document.getElementById("dv-dash");
await customElements.whenDefined("mv-deja-vu");
const $ = (sel) => dv.querySelector(sel);
const k = (name) => $(`[data-k="${name}"]`);
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
const cents = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
const count = document.getElementById("dv-count");
const threshold = document.getElementById("dv-threshold");
const share = document.getElementById("dv-share");
const pick = (list) => list[Math.floor(Math.random() * list.length)];
const num = (el) => Number(el.textContent.replace(/[^0-9.]/g, ""));
// The user's own edit: the derived text updates, and nothing is flagged.
const recompute = () => {
const v = Number(threshold.value) || 0;
share.textContent = `${Math.max(4, Math.min(98, Math.round(100 - v * 0.48)))}%`;
};
threshold.addEventListener("input", recompute);
const FLOW = ["Processing", "Packed", "Shipped", "Delivered"];
const VARIANT = { Processing: "warning", Packed: "info", Shipped: "success", Delivered: "success" };
const CUSTOMERS = ["Priya Raman", "Tomás Silva", "Grace Kim", "Noah Fischer", "Leila Haddad", "Oliver Brown"];
let nextId = 4822;
const rows = document.getElementById("dv-rows");
const updates = [
() => { const el = k("revenue"); el.textContent = usd.format(num(el) + 80 + Math.round(Math.random() * 640)); },
() => { const el = k("orders"); el.textContent = String(num(el) + 1 + Math.floor(Math.random() * 3)); },
() => { const el = k("conversion"); el.textContent = `${(num(el) + (Math.random() < 0.6 ? 0.1 : -0.1)).toFixed(1)}%`; },
() => {
const live = [...rows.querySelectorAll("[data-s]")].filter((s) => s.textContent !== "Delivered");
if (!live.length) return false;
const s = pick(live);
const next = FLOW[FLOW.indexOf(s.textContent) + 1];
s.textContent = next;
s.closest(".mv-badge").dataset.variant = VARIANT[next];
},
() => {
const id = nextId++;
const tr = document.createElement("tr");
tr.dataset.dejaVuUnit = `Order #${id}`;
const cells = [`#${id}`, pick(CUSTOMERS), cents.format(40 + Math.round(Math.random() * 260))];
cells.forEach((text, i) => { const td = document.createElement("td"); td.textContent = text; if (i === 1) td.className = "dv-who"; if (i === 2) td.className = "dv-num"; tr.append(td); });
const td = document.createElement("td");
const badge = document.createElement("span");
badge.className = "mv-badge"; badge.dataset.variant = "warning"; badge.dataset.shape = "pill";
const dot = document.createElement("span"); dot.className = "mv-badge-dot";
const label = document.createElement("span"); label.dataset.s = ""; label.textContent = "Processing";
badge.append(dot, label); td.append(badge); tr.append(td);
rows.prepend(tr);
while (rows.children.length > 5) rows.lastElementChild.remove();
},
() => {
// A server-synced value: only when you are not editing it.
if (document.activeElement === threshold) return false;
const v = String(Number(threshold.value) === 75 ? 80 : 75);
threshold.value = v;
threshold.setAttribute("value", v);
recompute();
},
() => { const el = k("refunds"); const n = num(el); el.textContent = String(n > 5 ? n - 1 : n + 1); },
];
const change = () => { for (let i = 0; i < 4 && pick(updates)() === false; i++); };
let flagged = 0;
dv.addEventListener("mv-deja-vu", () => { count.textContent = String(++flagged); });
// Random system changes every 2–4 s; stops by itself once the dashboard is removed.
let timer = 0;
const loop = () => {
if (!dv.isConnected) return clearTimeout(timer);
change();
timer = setTimeout(loop, 2000 + Math.random() * 2000);
};
timer = setTimeout(() => { if (!dv.isConnected) return; updates[0](); updates[3](); timer = setTimeout(loop, 2600); }, 350);
document.getElementById("dv-now").addEventListener("click", change);
document.getElementById("dv-ack").addEventListener("click", () => dv.acknowledge());
</script>
</div>Cultural reference
The Matrix — Lana and Lilly Wachowski (1999, film). A déjà vu — the same black cat crossing a doorway twice — is the tell that the system has just changed something behind your back. In the UI, a value the system changed without you plays its transition twice in a quick stutter, so silent changes become noticeable while your own edits never trigger it.
API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
grace | number (ms) | 1500 | A change counts as the user's own (never flagged) when they pressed, typed, pasted or changed a field inside that unit within this window — e.g. a total recalculated from what they are typing. |
echo | number (ms) | 300 | Region-wide window after any interaction inside the region during which every change is considered a reaction to it (a click that re-renders another card). 0 disables it. |
cooldown | number (ms) | 4000 | Minimum time between two double takes of the same unit. Changes in between still refresh its marker and are still reported. |
linger | number (ms) | 8000 | Time a flagged unit must spend on screen (at least half of it, tab visible) before its marker fades on its own. 0 keeps markers until hover, focus or acknowledge(). |
observe-attributes | string | data-state, data-status, aria-*, value, src, datetime | Watched attributes (comma- or space-separated), on top of text and inserted nodes which are always watched. “aria-*” covers the common ARIA states; add “class” explicitly. A field's value attribute is treated like text (ghost of the old value). |
announce | boolean | false | Polite live-region summary of system changes, throttled to one message every 4 s at most: “Revenue today changed to $48,930.” or “3 values changed: Revenue today, Status of Order #4821, Orders.” Never triggered by the user's own edits. |
data-deja-vu-unit | string | On an element inside the region: makes it a unit, with this label. Otherwise the nearest li, tr, [role=listitem], [role=row], article, figure, fieldset or section (label: aria-label, heading, legend or first cell), else the region's direct child. | |
data-deja-vu-label | string | On a part of a unit: name of that value in announcements (“Status of Order #4821”). Table cells use their column header by default. | |
data-deja-vu-ignore | boolean | Subtree never observed (clocks, sparklines, counters that tick on purpose). | |
data-deja-vu | "changed" | Set by the component on a flagged unit until it is acknowledged (style hook). |
Properties
| Name | Type | Description |
|---|---|---|
changes | { unit, kind, label, field, before, after, time, count }[] | Unacknowledged system changes, newest first (read-only copies). kind: "changed" or "added"; before is the value before the first unacknowledged change, after the current one; count: how many times the unit changed since. |
grace / echo / cooldown / linger / observeAttributes / announce | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
acknowledge(unit?) | Acknowledges one unit (element inside it, or selector) or, with no argument, every change: markers fade out and mv-acknowledge fires. Returns the number of changes acknowledged. |
flag(target, { before?, after? }) | Reports a change the observer cannot see (a value set through a property only, a canvas redraw). Same rules as observed changes: grace, cooldown, batching. |
Events
| Name | Description |
|---|---|
mv-deja-vu | A system change was flagged (once per unit per frame). detail: { unit, kind, label, field, before, after, time, count }. |
mv-acknowledge | Changes were acknowledged (hover, focus, time on screen or acknowledge()). detail: { changes, remaining }. |
Content structure
| Name | Description |
|---|---|
(content) | Any live region. Ghosts, pulses and markers are drawn in an aria-hidden overlay layer appended to the element; the app's DOM only receives data-deja-vu on flagged units and short opacity animations on changed values. |
CSS classes
| Name | Description |
|---|---|
mv-deja-vu-flash | The unit's double pulse (transient). |
mv-deja-vu-ghost | The previous value crossing the new one twice (transient; copies the value's font, color, padding and alignment). |
mv-deja-vu-mark / -marker | Persistent outline of a flagged unit and its “Changed · 12s ago” tab. data-leaving while fading out. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-deja-vu-color | var(--mv-accent) | Tint of pulses, outline and marker. |
--mv-deja-vu-outline | 38% | Strength of the persistent outline (color-mix percentage). |
Accessibility
Visual cues are aria-hidden with pointer-events: none and never move focus or alter the flagged unit's semantics (only a data-deja-vu attribute and brief opacity animations on the changed value). With announce, screen-reader users get one polite, throttled summary per burst of system changes (at most one message every 4 s, names first, “and 2 more” beyond three) — never for their own typing, since changes inside a unit they touched within grace ms are ignored by design. Acknowledgement works for every input: keyboard focus inside a unit acknowledges it immediately, as does hovering it for 350 ms or keeping it on screen for linger ms; acknowledge() lets the app offer an explicit “Mark all as seen”. Markers pair color with a text label and an icon, and use Highlight in forced colors. Reduced motion (prefers-reduced-motion or data-motion="reduce"): no ghost and no stutter, the unit simply gets its static outline and marker. Performance: one MutationObserver whose callback does no layout reads (text and cached lookups only); records are batched per animation frame, where every rect is read before any overlay is written; the relative-time clock ticks once per second only while markers exist and the tab is visible; everything is released when the element is removed. Password fields are never read.