Exclusivobeta
Deja Vu <mv-deja-vu>
Señala, al instante y en su sitio, los cambios que el sistema hizo a espaldas del usuario, nunca los que provocó el propio usuario. Envuelve una región en vivo (feed, dashboard, tabla, formulario sincronizado con el servidor); cuando una unidad (fila, tarjeta, fieldset… o cualquier elemento marcado con data-deja-vu-unit) cambia y el usuario no ha apuntado, escrito ni pulsado una tecla dentro de ella durante los últimos grace ms, hace una doble toma: un fantasma del valor anterior cruza dos veces el nuevo mientras el nuevo valor repite su entrada, dos pulsos idénticos en ~600 ms. Después, una pequeña pestaña “Cambiado · hace 12 s” (“Nuevo” para las filas insertadas) y un contorno tenue permanecen en la unidad, con un tiempo relativo que se actualiza con bajo coste, hasta que la unidad recibe el puntero, el foco o lleva linger ms en pantalla. Los cambios se agrupan por fotograma, una unidad nunca repite su doble toma más de una vez por cooldown y la lista de cambios no confirmados se expone como changes (acknowledge() la vacía); un anuncio cortés opcional los resume (“3 valores cambiados: Ingresos de hoy, Estado del pedido n.º 4821, Conversión.”).
| Categoría | Feedback |
|---|---|
| Tipo | Web Component (<mv-deja-vu>) |
| Estado | beta |
| Kit | Estados del sistema honestos |
| 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
Instalación
node scripts/add.mjs deja-vu --out ./src/marvelousAgente de IA con el servidor MCP de Marvelous UI: install_components({ slugs: ["deja-vu"], 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/deja-vu/deja-vu.js, components/deja-vu/deja-vu.css.
Uso
Inicio rápido, el marcado mínimo que funciona:
<mv-deja-vu>
<ul><li>Order #4821 · <span>Packed</span></li><li>Order #4820 · <span>Processing</span></li></ul>
</mv-deja-vu>Marcado de referencia: parte de él y personalízalo con atributos, data-* y variables CSS:
<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>Referencia cultural
Matrix, Lana y Lilly Wachowski (1999, película). Un déjà vu, el mismo gato negro que cruza dos veces una puerta, es la señal de que el sistema acaba de cambiar algo a tus espaldas. En la interfaz, un valor que el sistema cambió sin ti reproduce su transición dos veces en un rápido tartamudeo, para que los cambios silenciosos se noten, mientras que tus propias ediciones nunca lo activan.
API
Attributes
| Name | Tipo | 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 | Tipo | 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.