Exclusivebeta
Unobserved — <mv-unobserved>
A live region (feed, leaderboard, order book, notification list, server-synced kanban) that never reorders or shifts while someone is looking at it. Looking means the pointer is inside, a press started inside (drags included), focus or a text selection is inside, or there was scroll, wheel, key or touch activity inside in the last few seconds, and the region is on screen with the tab visible; attention="visible" makes being on screen enough. Changes that arrive meanwhile, through queue(fn), setItems() with keyed rendering, or plain DOM mutations of the lists when watch is on (framework-rendered lists stay frozen in place with inverse transforms, new rows wait out of the flow, removed ones stay as inert ghosts), are held behind a discreet “3 updates waiting · Show now” chip that never takes layout space. When attention leaves (after a grace delay), when the chip is used, or gently after max-hold so nothing is held forever, everything lands at once with a FLIP so each move stays traceable, and moved, new, changed and urgent rows get a brief outline. Urgent changes break the hold, a key coalesces repeated updates of the same row, focus and scroll position are preserved, and the pending count and every applied batch are announced politely.
| Category | Data display |
|---|---|
| Type | Web Component (<mv-unobserved>) |
| Status | beta |
| 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
Install
node scripts/add.mjs unobserved --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["unobserved"], 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/focus.js, core/motion.js, core/observe.js, components/unobserved/unobserved.js, components/unobserved/unobserved.css.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<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 events", waitingOne: "1 new event", show: "Show",
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>Cultural reference
Doctor Who, “Blink” — Steven Moffat (BBC) (2007, series). The stone creatures of the episode can only move while nobody is looking at them, and freeze the instant someone does. In the UI, a live list only reorders while unobserved: every change is frozen as long as a pointer, focus, selection or recent activity rests on it, then everything moves at once the moment attention leaves.
API
Attributes
| Name | Type | 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 | Type | 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.