Exclusivobeta
Unobserved <mv-unobserved>
Una región en vivo (feed, clasificación, libro de órdenes, lista de notificaciones, kanban sincronizado con el servidor) que nunca se reordena ni se desplaza mientras alguien la está mirando. Mirar significa que el puntero está dentro, que una pulsación empezó dentro (arrastres incluidos), que el foco o una selección de texto están dentro, o que hubo actividad de desplazamiento, rueda, teclado o táctil dentro en los últimos segundos, y que la región está en pantalla con la pestaña visible; attention="visible" hace que baste con estar en pantalla. Los cambios que llegan mientras tanto, mediante queue(fn), setItems() con renderizado por claves, o simples mutaciones del DOM de las listas cuando watch está activo (las listas renderizadas por un framework se quedan congeladas en su sitio con transformaciones inversas, las filas nuevas esperan fuera del flujo, las eliminadas se quedan como fantasmas inertes), se retienen tras un discreto chip “3 actualizaciones en espera · Mostrar ahora” que nunca ocupa espacio en el layout. Cuando la atención se va (tras un margen de cortesía), cuando se usa el chip, o suavemente después de max-hold para que nada quede retenido para siempre, todo llega a la vez con un FLIP para que cada movimiento se pueda seguir, y las filas movidas, nuevas, cambiadas y urgentes reciben un breve contorno. Los cambios urgentes rompen la retención, una key agrupa las actualizaciones repetidas de la misma fila, el foco y la posición de desplazamiento se conservan, y el recuento pendiente y cada lote aplicado se anuncian en modo polite.
| Categoría | Visualización de datos |
|---|---|
| Tipo | Web Component (<mv-unobserved>) |
| Estado | beta |
| Kit | Supervisión sin fatiga de alertas |
| Keywords | exclusive, culture, live-data, realtime, feed, leaderboard, order-book, kanban, notifications, reorder, layout-shift, stable-layout, buffer, pending-updates, new-items-pill, attention, flip, focus-preservation, scroll-anchoring, mutation-observer |
When to use
- A leaderboard, order book or ranked list re-sorts from live data and users need to click rows without them jumping away
- A notification or activity feed receives new items while someone reads it, and inserts must not push the text they are reading
- A kanban board or queue synced from the server must not move a card while it is hovered, focused or dragged
- A framework re-renders a list on every server message and the layout must stay still until the user looks away
Avoid when
- Changes should land instantly and simply be pointed out once they have happened → use Deja Vu instead
- The user needs a summary of what changed while the tab was hidden or they were away → use While Away instead
- The values are a ticker updated many times per second where every tick matters: holding would show stale prices
Instalación
node scripts/add.mjs unobserved --out ./src/marvelousAgente de IA con el servidor MCP de Marvelous UI: install_components({ slugs: ["unobserved"], 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/focus.js, core/motion.js, core/observe.js, components/unobserved/unobserved.js, components/unobserved/unobserved.css.
Uso
Inicio rápido, el marcado mínimo que funciona:
<mv-unobserved watch>
<ol data-unobserved-list>
<li>Maya Chen · $48,200</li>
<li>Diego Alvarez · $41,900</li>
<li>Priya Natarajan · $39,400</li>
</ol>
</mv-unobserved>Marcado de referencia: parte de él y personalízalo con atributos, data-* y variables CSS:
<div id="uo-demo" style="width:min(100%,64rem);margin-inline:auto">
<style>
#uo-demo { --uo-row: 3.25rem }
#uo-demo .uo-layout { display:grid; grid-template-columns:minmax(0,1fr) 19rem; gap:1.25rem; align-items:start }
#uo-demo .uo-card { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs) }
#uo-demo .uo-head { display:flex; align-items:center; justify-content:space-between; gap:.75rem; padding:1rem 1.125rem .75rem }
#uo-demo .uo-head h3, #uo-demo .uo-head h4 { margin:0; font-size:1rem; font-weight:650; letter-spacing:-.01em }
#uo-demo .uo-head p { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.75rem }
#uo-demo .uo-pulse { display:inline-block; width:.4rem; height:.4rem; margin-inline-end:.35rem; border-radius:50%; background:currentColor; vertical-align:.08em }
#uo-demo .uo-cols, #uo-demo .uo-row { display:grid; grid-template-columns:2.25rem minmax(0,1fr) 3.5rem 7.25rem 5.5rem; align-items:center; gap:.75rem; padding:0 1.125rem }
#uo-demo .uo-cols { padding-block:.5rem; border-block:1px solid var(--mv-border); background:var(--mv-bg-subtle); color:var(--mv-fg-muted); font-size:.6875rem; font-weight:600; letter-spacing:.04em; text-transform:uppercase }
#uo-demo .uo-cols span:nth-child(3), #uo-demo .uo-cols span:nth-child(4) { text-align:end }
#uo-demo .uo-list { list-style:none; margin:0; padding:.25rem 0 .375rem }
#uo-demo .uo-row { min-height:var(--uo-row); border-radius:var(--mv-radius-lg); margin-inline:.375rem; padding-inline:.75rem; background:var(--mv-surface) }
#uo-demo .uo-row:hover { background:var(--mv-bg-subtle) }
#uo-demo .uo-rank { display:grid; place-items:center; width:1.75rem; height:1.75rem; border-radius:50%; color:var(--mv-fg-muted); font-size:.8125rem; font-weight:600; font-variant-numeric:tabular-nums }
#uo-demo .uo-row[data-top] .uo-rank { background:var(--mv-accent-subtle); color:var(--mv-accent) }
#uo-demo .uo-rep { display:flex; align-items:center; gap:.625rem; min-width:0 }
#uo-demo .uo-av { flex:none; display:grid; place-items:center; width:2rem; height:2rem; border-radius:50%; background:oklch(0.56 0.15 var(--h)); color:var(--mv-fg-on-accent); font-size:.6875rem; font-weight:650; letter-spacing:.02em }
#uo-demo .uo-who { display:grid; min-width:0 }
#uo-demo .uo-who b { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:.875rem; font-weight:600 }
#uo-demo .uo-who small { color:var(--mv-fg-muted); font-size:.75rem }
#uo-demo .uo-deals { text-align:end; color:var(--mv-fg-muted); font-size:.8125rem; font-variant-numeric:tabular-nums }
#uo-demo .uo-rev { display:grid; justify-items:end; font-variant-numeric:tabular-nums }
#uo-demo .uo-rev b { font-size:.875rem; font-weight:650 }
#uo-demo .uo-rev small { min-height:1em; color:var(--mv-success); font-size:.6875rem; font-weight:600 }
#uo-demo .uo-rev small[data-down] { color:var(--mv-danger) }
#uo-demo .uo-row .mv-button { justify-self:end }
#uo-demo .uo-row[data-unobserved-moved="up"] .uo-rank { color:var(--mv-success) }
#uo-demo .uo-side { display:grid; gap:1rem }
#uo-demo .uo-hud { padding:1rem 1.125rem; display:grid; gap:.875rem; font-size:.8125rem }
#uo-demo .uo-hud h4 { display:flex; align-items:center; justify-content:space-between; gap:.5rem; margin:0; font-size:.875rem; font-weight:650 }
#uo-demo .uo-signals { list-style:none; margin:0; padding:0; display:grid; grid-template-columns:1fr 1fr; gap:.375rem }
#uo-demo .uo-signals li { display:flex; align-items:center; gap:.5rem; padding:.375rem .5rem; border-radius:var(--mv-radius-md); background:var(--mv-bg-subtle); color:var(--mv-fg-muted); font-size:.75rem; transition:background-color var(--mv-duration-fast), color var(--mv-duration-fast) }
#uo-demo .uo-signals li::before { content:""; flex:none; width:.5rem; height:.5rem; border-radius:50%; background:var(--mv-border-strong); transition:background-color var(--mv-duration-fast), box-shadow var(--mv-duration-fast) }
#uo-demo .uo-signals li[data-on] { background:var(--mv-accent-subtle); color:var(--mv-fg) }
#uo-demo .uo-signals li[data-on]::before { background:var(--mv-accent); box-shadow:0 0 0 3px color-mix(in oklab, var(--mv-accent) 22%, transparent) }
#uo-demo .uo-status { margin:0; color:var(--mv-fg-muted); font-size:.75rem; line-height:1.45; min-height:2.9em }
#uo-demo .uo-status b { color:var(--mv-fg); font-weight:600 }
#uo-demo .uo-actions { display:flex; gap:.5rem; flex-wrap:wrap }
#uo-demo .uo-sep { height:1px; background:var(--mv-border) }
#uo-demo .uo-feed .uo-head { padding-bottom:.5rem }
#uo-demo #uo-feed { --mv-unobserved-offset:1.125rem; --mv-unobserved-inset:1rem }
#uo-demo #uo-board { --mv-unobserved-offset:1px }
#uo-demo .uo-events { list-style:none; margin:0; padding:.5rem .5rem .625rem; display:grid; gap:.125rem; min-height:16.5rem; align-content:start }
#uo-demo .uo-ev { display:grid; grid-template-columns:auto minmax(0,1fr); gap:.125rem .625rem; align-items:start; padding:.5rem .625rem; border-radius:var(--mv-radius-lg); background:var(--mv-surface); font-size:.75rem; line-height:1.4 }
#uo-demo .uo-ev .uo-av { grid-row:span 2; width:1.625rem; height:1.625rem; font-size:.625rem }
#uo-demo .uo-ev p { margin:0; color:var(--mv-fg) }
#uo-demo .uo-ev p b { font-weight:600 }
#uo-demo .uo-ev time { color:var(--mv-fg-subtle); font-size:.6875rem }
#uo-demo .uo-hint { margin:.875rem 0 0; color:var(--mv-fg-muted); font-size:.75rem; text-align:center }
@media (max-width:54rem) {
#uo-demo .uo-layout { grid-template-columns:minmax(0,1fr) }
}
@media (max-width:36rem) {
#uo-demo .uo-cols, #uo-demo .uo-row { grid-template-columns:2rem minmax(0,1fr) 6rem; gap:.5rem }
#uo-demo .uo-cols span:nth-child(3), #uo-demo .uo-deals, #uo-demo .uo-cols span:nth-child(5), #uo-demo .uo-row .mv-button { display:none }
}
</style>
<div class="uo-layout">
<section class="uo-card" aria-labelledby="uo-board-title">
<div class="uo-head">
<div>
<h3 id="uo-board-title">Sales leaderboard</h3>
<p>Q3 closed-won revenue · synced from the CRM</p>
</div>
<span class="mv-badge" data-variant="success" data-shape="pill" id="uo-live"><span class="uo-pulse" aria-hidden="true"></span>Live</span>
</div>
<mv-unobserved id="uo-board" grace="900ms" max-hold="25s">
<div class="uo-cols" aria-hidden="true"><span>#</span><span>Rep</span><span>Deals</span><span>Revenue</span><span></span></div>
<ol class="uo-list" data-unobserved-list aria-label="Sales reps ranked by revenue"></ol>
</mv-unobserved>
</section>
<aside class="uo-side">
<div class="uo-card uo-hud" aria-label="Attention monitor">
<h4>Attention on the board <span class="mv-badge" data-variant="secondary" data-shape="pill" id="uo-state">Live</span></h4>
<ul class="uo-signals" id="uo-signals">
<li data-k="pointer">Pointer inside</li>
<li data-k="press">Press</li>
<li data-k="focus">Focus inside</li>
<li data-k="selection">Text selected</li>
<li data-k="activity">Scroll or keys</li>
<li data-k="hold">App hold</li>
</ul>
<p class="uo-status" id="uo-status">Nobody is looking: updates land live, with a glide.</p>
<div class="uo-sep"></div>
<label class="mv-choice" data-control="end">
<input type="checkbox" role="switch" class="mv-switch" id="uo-feed-on" checked>
<span class="mv-choice-text"><span class="mv-choice-title">CRM sync</span></span>
</label>
<div class="uo-actions">
<button class="mv-button" data-variant="outline" data-size="sm" type="button" id="uo-urgent">Reverse a deal (urgent)</button>
<button class="mv-button" data-variant="ghost" data-size="sm" type="button" id="uo-apply">Apply now</button>
</div>
</div>
<section class="uo-card uo-feed" aria-labelledby="uo-feed-title">
<mv-unobserved id="uo-feed" attention="visible" max-hold="12s" chip-align="end">
<div class="uo-head">
<div>
<h4 id="uo-feed-title">Team activity</h4>
<p>Held while on screen</p>
</div>
</div>
<ul class="uo-events" data-unobserved-list aria-label="Recent team activity"></ul>
</mv-unobserved>
</section>
</aside>
</div>
<p class="uo-hint">Hover the board and try to click “Message” while the CRM pushes new deals: nothing moves until you leave · Tab into a row: focus holds it too</p>
<script type="module">
const board = document.getElementById("uo-board");
const feed = document.getElementById("uo-feed");
await customElements.whenDefined("mv-unobserved");
const $ = (id) => document.getElementById(id);
const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
const initials = (name) => name.split(" ").map((w) => w[0]).join("").slice(0, 2).toUpperCase();
let reps = [
{ id: "pr", name: "Priya Raman", city: "Bengaluru", hue: 285, deals: 21, revenue: 184200, delta: 0 },
{ id: "lo", name: "Liam O'Connor", city: "Dublin", hue: 155, deals: 19, revenue: 176900, delta: 0 },
{ id: "sm", name: "Sofia Martínez", city: "Madrid", hue: 25, deals: 18, revenue: 171400, delta: 0 },
{ id: "kw", name: "Kenji Watanabe", city: "Tokyo", hue: 235, deals: 17, revenue: 168300, delta: 0 },
{ id: "ao", name: "Amara Okafor", city: "Lagos", hue: 60, deals: 16, revenue: 163800, delta: 0 },
{ id: "ls", name: "Lucas Schmidt", city: "Berlin", hue: 200, deals: 15, revenue: 159500, delta: 0 },
{ id: "cd", name: "Chloé Dubois", city: "Montréal", hue: 340, deals: 14, revenue: 154100, delta: 0 },
{ id: "mr", name: "Mateo Rossi", city: "Milan", hue: 110, deals: 13, revenue: 149700, delta: 0 },
];
const ranked = () => [...reps].sort((a, b) => b.revenue - a.revenue);
board.renderItem = (rep, row, i) => {
if (!row) {
row = el("li", "uo-row");
const av = el("span", "uo-av", initials(rep.name));
av.style.setProperty("--h", rep.hue);
av.setAttribute("aria-hidden", "true");
const who = el("span", "uo-who");
who.append(el("b", "", rep.name), el("small", "", rep.city));
const repCell = el("span", "uo-rep");
repCell.append(av, who);
const rev = el("span", "uo-rev");
rev.append(el("b"), el("small"));
const msg = el("button", "mv-button", "Message");
Object.assign(msg.dataset, { variant: "ghost", size: "sm" });
msg.type = "button";
msg.setAttribute("aria-label", `Message ${rep.name}`);
row.append(el("span", "uo-rank"), repCell, el("span", "uo-deals"), rev, msg);
}
row.toggleAttribute("data-top", i < 3);
row.querySelector(".uo-rank").textContent = i + 1;
row.querySelector(".uo-deals").textContent = rep.deals;
row.querySelector(".uo-rev b").textContent = usd.format(rep.revenue);
const d = row.querySelector(".uo-rev small");
d.textContent = rep.delta ? `${rep.delta > 0 ? "+" : "−"}${usd.format(Math.abs(rep.delta))}` : "";
d.toggleAttribute("data-down", rep.delta < 0);
return row;
};
board.items = ranked();
// The CRM pushes a closed deal every 1.4 s; the board gets a fresh ranking each time.
const push = (rep, amount, opts) => {
reps = reps.map((r) => r.id === rep.id ? { ...r, revenue: r.revenue + amount, deals: r.deals + Math.sign(amount), delta: amount } : r);
board.setItems(ranked(), opts);
};
let tick = 0;
const timer = setInterval(() => {
if (!board.isConnected) { clearInterval(timer); return; }
if (!$("uo-feed-on").checked) return;
tick++;
// Mostly the chasing pack, so ranks really change.
const pool = ranked().slice(tick % 3 === 0 ? 0 : 2);
const rep = pool[Math.floor(Math.random() * pool.length)];
push(rep, 1000 * (4 + Math.floor(Math.random() * 11)));
if (tick % 2) addEvent(rep);
}, 1400);
$("uo-urgent").addEventListener("click", () => {
const top = ranked()[0];
push(top, -24000, { urgent: true, label: `${top.name}'s $24,000 deal was reversed` });
});
$("uo-apply").addEventListener("click", () => board.apply());
// Attention monitor.
const signals = [...$("uo-signals").children];
const state = $("uo-state");
const status = $("uo-status");
const paint = () => {
const n = board.pending;
if (board.holding) {
state.textContent = `Holding ${n}`;
state.dataset.variant = "warning";
status.replaceChildren("Someone is looking: ", el("b", "", `${n} ${n === 1 ? "update" : "updates"} held`), ". They land when attention leaves, or after 25 s.");
} else if (board.observed) {
state.textContent = "Watching";
state.dataset.variant = "info";
status.replaceChildren("Someone is looking: the next update will be held.");
} else {
state.textContent = "Live";
state.dataset.variant = "secondary";
status.replaceChildren("Nobody is looking: updates land live, with a glide.");
}
};
board.addEventListener("mv-attention", (e) => {
for (const li of signals) li.toggleAttribute("data-on", e.detail.reasons.includes(li.dataset.k));
paint();
});
board.addEventListener("mv-hold", paint);
board.addEventListener("mv-applied", paint);
// Activity feed: plain DOM changes through queue(), held while the feed is on screen.
feed.strings = {
waiting: "{count} new", waitingOne: "1 new", show: "Show", // short: the chip shares the heading row of a narrow card
announceHold: "{count} new events waiting. Choose Show to see them.",
announceHoldOne: "1 new event waiting. Choose Show to see it.",
};
const list = feed.querySelector("[data-unobserved-list]");
const clients = ["Northwind Retail", "Hanamura Foods", "Blue Harbor Logistics", "Aster Health", "Kestrel Energy", "Lumen Studio", "Orchid Hotels", "Pinecrest Bank"];
const eventRow = (rep, text, time) => {
const li = el("li", "uo-ev");
const av = el("span", "uo-av", initials(rep.name));
av.style.setProperty("--h", rep.hue);
av.setAttribute("aria-hidden", "true");
const p = el("p");
p.append(el("b", "", rep.name), ` ${text}`);
li.append(av, p, el("time", "", time));
return li;
};
const seed = [
[reps[3], "closed Hanamura Foods · $12,000", "2 min ago"],
[reps[0], "moved Aster Health to negotiation", "6 min ago"],
[reps[4], "closed Kestrel Energy · $9,000", "11 min ago"],
[reps[6], "booked a demo with Orchid Hotels", "18 min ago"],
];
list.append(...seed.map(([r, t, when]) => eventRow(r, t, when)));
let n = 0;
function addEvent(rep) {
const client = clients[n++ % clients.length];
const row = eventRow(rep, `closed ${client}`, "Just now");
feed.queue(() => {
list.prepend(row);
const rows = list.querySelectorAll(":scope > .uo-ev:not(.mv-unobserved-ghost)");
for (let i = 5; i < rows.length; i++) rows[i].remove();
}, { label: `${rep.name} closed ${client}` });
}
// Two events right away, so the held state is visible from the start.
setTimeout(() => board.isConnected && addEvent(reps[2]), 300);
setTimeout(() => board.isConnected && addEvent(reps[5]), 800);
paint();
</script>
</div>Referencia cultural
Doctor Who, “Parpadeo”, Steven Moffat (BBC) (2007, serie). Las criaturas de piedra del episodio solo pueden moverse mientras nadie las mira, y se congelan en el instante en que alguien lo hace. En la interfaz, una lista en vivo solo se reordena cuando nadie la observa: cada cambio queda congelado mientras un puntero, el foco, una selección o una actividad reciente estén sobre ella, y todo se mueve a la vez en cuanto la atención se va.
API
Attributes
| Name | Tipo | Default | Description |
|---|---|---|---|
grace | time ("1s", "900ms", ms number) | 1s | How long attention must be gone before held changes land. Changes that arrive during this window are held too, so a pointer overshooting the edge does not trigger a reshuffle. |
max-hold | time | 20s | Longest a change is held while someone keeps looking (minimum 1s). Then everything lands in a slower, gentle FLIP with focus and scroll preserved; a thin line in the chip shows the time left. Deferred while a press or drag is in progress. Not applied to an explicit hold(). |
idle | time | 4s | How long scroll, wheel, key or touch activity inside the region keeps counting as attention (touch screens have no hover, so this is what holds changes while someone reads on a phone). |
attention | interaction | visible | interaction | interaction: pointer, press, focus, selection or recent activity inside. visible: being on screen with the tab visible is enough (a news feed that shows “12 new posts” instead of inserting them, for screen reader and eye-only reading); changes then land through the chip, max-hold, scrolling away or hiding the tab. |
placement | top | bottom | top | Where the chip floats. It is sticky inside the region (stays visible while a tall region scrolls) and absolutely positioned, so it never moves content. |
chip-align | start | center | end | center | Horizontal position of the chip in the region, e.g. end to sit it in the empty corner of a card header. |
watch | boolean | Also hold child additions, removals and reorders of the [data-unobserved-list] containers made by anyone (React, Vue, Svelte, htmx…). While held, kept rows are pinned to where they were seen with inverse translate, new rows get data-unobserved-held="added" (display: none), removed rows are replaced by inert ghost clones and the list height is locked. Text and attribute changes inside rows are not held. A width change of a frozen list forces the apply (reason "layout"). | |
announce | polite | off | polite | Screen reader announcements: when changes start being held, the count every 6 s at most while it grows, and a summary of each applied batch (“3 updates applied: 1 new, 2 moved.”). Urgent changes use an assertive region. |
data-unobserved-list | marker attribute | On the container(s) whose element children are the moving items (one per kanban column is fine; moves between lists are animated). Without any, the component's own children are the items; in items mode a list is created when missing. | |
data-unobserved-moved / -new / -changed / -urgent | set by the component | On items for ~1.8 s after they landed: moved is "up" or "down" (only rows that actually changed rank, not the ones pushed by an insert), new for inserted rows, changed for rows whose data or text changed, urgent for rows of an urgent batch. Style them freely. | |
data-observed / data-holding | set by the component | On the host while someone is looking / while changes are held. data-unobserved-frozen on a list frozen in watch mode, data-unobserved-flying on rows mid-FLIP. |
Properties
| Name | Tipo | Description |
|---|---|---|
items | unknown[] | Keyed rendering: assign the full, latest array (typically on every server message). The first assignment renders at once; later ones are diffed by key and held while observed. Reading returns the latest array, held or not. |
renderItem | (item, element | null, index) => Element | Creates a row (element is null) or updates the existing one for the same key in place and returns it. Called when the item object or its index changed. Default: an <li> (or a role="listitem" <div>) showing item.label, name or title. |
itemKey | (item, index) => string | number | Identity of an item across updates. Default: item.id, then item.key, else the index. Server-rendered children with a matching data-key are adopted. |
pending | number | Held changes (read-only): queued jobs, plus rows affected by held items, plus rows added, removed or reordered in watch mode. |
observed | boolean | Whether someone is looking right now (read-only). |
reasons | string[] | Why it counts as observed (read-only): "pointer", "press", "focus", "selection", "activity", "visible", "hold". |
holding | boolean | True while changes are held and the chip is shown (read-only). |
changes | Array<{ source, label?, key?, time?, count? }> | Held changes, oldest first (read-only). source: "queue" (one per job), "items" (with count and labels) or "watch" (with count). |
strings | Partial<Record<string, string>> | Overrides for every text: waiting ({count}), waitingOne, show, announceHold, announceHoldOne, announceCount, announceApplied ({count}, {parts}), announceAppliedOne, announceUrgent ({label}), added / removed / moved / changed ({n}). English defaults. |
Methods
| Name | Description |
|---|---|
queue(fn, { label?, key?, urgent? }) | Runs fn (any DOM change of the region: prepend a row, move a card to another column…) right away if nobody is looking, batched per frame and animated, else holds it. key coalesces: a newer job with the same key replaces the held one. urgent breaks the hold and applies everything pending, in order. Returns Promise<boolean>: true once applied, false if replaced, discarded or if fn threw. |
setItems(items, { urgent?, label? }) | Same as assigning items, with an urgent flag (a correction that must be seen now) and a label used in announcements and events. Returns false when the new array changes nothing. |
apply(fn?, { label? }) | Applies every held change now (then fn, if given) with the usual FLIP. Cannot be vetoed. Returns the same summary as mv-applied, or null when there was nothing to apply. |
discard() | Drops held queue jobs (their promises resolve false) and held items (the rendered ones stay). Watched DOM changes are already in the DOM and stay held. Returns how many were dropped. |
hold() | Holds changes regardless of attention, e.g. while a dialog about one row is open elsewhere or during a custom drag. Returns release(); several holds stack. max-hold does not override it. |
Events
| Name | Description |
|---|---|
mv-attention | Attention changed. detail: { observed, reasons, cause }. |
mv-hold | A change was held. detail: { pending, first (true for the first change of a hold), label }. |
mv-apply | Cancelable, before held or live changes land. detail: { reason: "live" | "leave" | "offscreen" | "hidden" | "max-hold", pending, labels }. preventDefault() keeps them held until the next chance (attention leaving again, max-hold restarted, Show now). "show-now", "urgent", "api", "layout" and "disconnect" cannot be vetoed and do not emit it. |
mv-applied | Changes landed. detail: { reason, count, added, removed, moved, changed (element arrays; removed elements are detached), labels }. Also emitted with reason "live" for unheld mutations seen in watch mode. |
Content structure
| Name | Description |
|---|---|
(content) | Any markup. Items are the element children of each [data-unobserved-list] (or of the component). The component prepends the chip layer (first in tab order) and appends two visually hidden live regions; both are ignored as items. |
CSS classes
| Name | Description |
|---|---|
mv-unobserved-chip | The “N updates waiting · Show now” button (data-open while shown, data-count). Parts: mv-unobserved-icon, -count, -sep, -action, -meter (time left before max-hold). |
mv-unobserved-layer | Absolutely positioned, pointer-events: none layer that holds the sticky chip (data-placement). |
mv-unobserved-ghost | Inert, aria-hidden clone standing in for a removed row: data-unobserved-ghost="held" (dimmed, dashed) while held in watch mode, "exit" while it fades out. |
mv-unobserved-list | The list created in items mode when no [data-unobserved-list] exists (role="list"). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-unobserved-color | var(--mv-accent) | Chip icon, action text and meter. |
--mv-unobserved-offset | var(--mv-space-3) | Distance of the chip from the region edge (and from the viewport edge while it sticks). |
--mv-unobserved-moved-color | var(--mv-accent) | Outline of moved rows. |
--mv-unobserved-new-color | var(--mv-success) | Outline of inserted rows. |
--mv-unobserved-changed-color | accent at 55% | Outline of rows whose content changed in place. |
--mv-unobserved-urgent-color | var(--mv-danger) | Outline of rows landed by an urgent change. |
--mv-unobserved-mark-width | 2px | Outline width (drawn inside the row, so it is never clipped). |
--mv-unobserved-mark-duration | 1.8s | How long the outline takes to fade. |
--mv-unobserved-ghost-opacity | 0.45 | Opacity of a removed row held in place (watch mode). |
Accessibility
The point of the component is that nothing moves under a user's pointer, keyboard focus or text selection: held changes are applied only once attention has left, so a click or a keystroke always reaches the row the user aimed at. Focus is never stolen: when changes land, the focused element keeps focus (re-focused with preventScroll if a move blurred it, including rows moved by a framework in watch mode); if its row was removed, focus goes to the row now at the same position, then to the list (tabindex="-1"). Scroll position is preserved: when the user has scrolled into the list, the first visible row (or the focused one) is kept at the same screen position across the apply, and the browser's own scroll anchoring is disabled for that moment so it never corrects twice. The chip is a real <button> (“3 updates waiting · Show now”, the dot is aria-hidden) that comes first in the region's tab order, so keyboard users reach it before the rows, and only while shown (visibility: hidden otherwise); activating it applies the changes and, since the chip disappears, moves focus to the list. A visually hidden polite live region says when changes start being held and how to see them, updates the count at most every 6 s, and summarizes each applied batch (“3 updates applied: 1 new, 2 moved.”); urgent changes use a separate assertive region; announce="off" silences both. Ghost rows are aria-hidden and inert, and held new rows are display: none, so assistive technologies read exactly what is on screen. Screen readers in browse mode move a virtual cursor that pages cannot detect: use attention="visible" or hold() for regions that are mostly read rather than clicked. Reduced motion (prefers-reduced-motion or data-motion="reduce"): no FLIP, no fade, no chip meter or count pulse; changes land instantly and the outlines stay static for their duration so moves remain traceable. Forced colors: the chip uses system button colors and the outlines use Highlight. Performance: nothing runs while idle (no loop); a batch measures every row once before and once after the change (read, write, read) and only animates rows near the viewport; arrivals within one frame are batched.