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.”).

CategoryFeedback
TypeWeb Component (<mv-deja-vu>)
Statusbeta
Keywordsexclusive, culture, feedback, change-detection, live-data, realtime, sync, dashboard, table, mutation-observer, highlight, diff, notification, awareness, collaboration

When to use

Avoid when

Install

node scripts/add.mjs deja-vu --out ./src/marvelous

AI 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

NameTypeDefaultDescription
gracenumber (ms)1500A 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.
echonumber (ms)300Region-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.
cooldownnumber (ms)4000Minimum time between two double takes of the same unit. Changes in between still refresh its marker and are still reported.
lingernumber (ms)8000Time 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-attributesstringdata-state, data-status, aria-*, value, src, datetimeWatched 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).
announcebooleanfalsePolite 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-unitstringOn 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-labelstringOn 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-ignorebooleanSubtree 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

NameTypeDescription
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 / announcereflectedMirror the attributes.

Methods

NameDescription
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

NameDescription
mv-deja-vuA system change was flagged (once per unit per frame). detail: { unit, kind, label, field, before, after, time, count }.
mv-acknowledgeChanges were acknowledged (hover, focus, time on screen or acknowledge()). detail: { changes, remaining }.

Content structure

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

NameDescription
mv-deja-vu-flashThe unit's double pulse (transient).
mv-deja-vu-ghostThe previous value crossing the new one twice (transient; copies the value's font, color, padding and alignment).
mv-deja-vu-mark / -markerPersistent outline of a flagged unit and its “Changed · 12s ago” tab. data-leaving while fading out.

CSS variables

NameDefaultDescription
--mv-deja-vu-colorvar(--mv-accent)Tint of pulses, outline and marker.
--mv-deja-vu-outline38%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.