Exclusivobeta
Busy Cursor <mv-busy-cursor>
“Espera, por favor” local para una región (una tabla que se recarga, un gráfico que se recalcula, un panel que se guarda): mientras está ocupada, un anillo diminuto acompaña al cursor del sistema con un progreso indeterminado o real, la región recibe cursor: progress y aria-busy, y los clics pueden retenerse y luego reproducirse una vez o descartarse con una sacudida. Lo que otros pasan por alto: solo se muestra en esperas de más de 300 ms, nunca reemplaza el cursor y anuncia un único “Loading…” / “Done” en modo polite desde fuera del subárbol aria-busy, donde los lectores de pantalla lo retendrían.
| Categoría | Cursores |
|---|---|
| Tipo | Web Component (<mv-busy-cursor>) |
| Estado | beta |
| Keywords | exclusive, light, cursor, busy, loading, aria-busy, progress, wait, spinner, pointer |
When to use
- A table, list or chart reloads in place and users keep clicking it because nothing near the pointer says wait
- One panel saves or recomputes while the rest of the page stays usable, so a page-wide spinner would be too blunt
- Clicks made during a short reload should wait and run once the data is fresh instead of acting on stale rows
- A background job reports real progress and the region should show it without covering the content
Avoid when
- The content is not there yet and needs placeholders in its final shape → use Skeleton instead
- The wait lasts minutes and needs an honest ETA, a queue position or a stuck warning → use Honest Wait instead
- The design wants a decorative custom cursor with a follower ring and labels → use Cursor Follower instead
Instalación
node scripts/add.mjs busy-cursor --out ./src/marvelousAgente de IA con el servidor MCP de Marvelous UI: install_components({ slugs: ["busy-cursor"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Archivos copiados (dependencias incluidas): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, components/busy-cursor/busy-cursor.js, components/busy-cursor/busy-cursor.css.
Uso
Inicio rápido, el marcado mínimo que funciona:
<mv-busy-cursor id="orders"> …your table… </mv-busy-cursor>
<!-- then: orders.track(fetch("/api/orders")) or orders.busy = true -->Marcado de referencia: parte de él y personalízalo con atributos, data-* y variables CSS:
<div id="mv-bc-demo">
<style>
#mv-bc-demo { display: grid; grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr)); gap: 1rem; width: min(100%, 56rem); margin-inline: auto; }
#mv-bc-demo .panel { display: grid; grid-template-rows: auto 1fr auto; border: 1px solid var(--mv-border); border-radius: var(--mv-radius-xl); background: var(--mv-surface); overflow: hidden; }
#mv-bc-demo .head { display: flex; align-items: center; justify-content: space-between; gap: .75rem; padding: .6rem .6rem .6rem 1rem; border-bottom: 1px solid var(--mv-border); }
#mv-bc-demo .head strong { font-size: .9rem; font-weight: var(--mv-weight-semibold); }
#mv-bc-demo .head span { display: block; color: var(--mv-fg-subtle); font-size: .75rem; }
#mv-bc-demo table { width: 100%; border-collapse: collapse; font-size: .82rem; }
#mv-bc-demo td { padding: .5rem 1rem; border-bottom: 1px solid var(--mv-border); white-space: nowrap; }
#mv-bc-demo tr:last-child td { border-bottom: 0; }
#mv-bc-demo td:nth-child(2) { color: var(--mv-fg-muted); width: 100%; }
#mv-bc-demo td.num { text-align: end; font-variant-numeric: tabular-nums; }
#mv-bc-demo td:last-child { padding-inline-start: 0; text-align: end; }
#mv-bc-demo .done { color: var(--mv-fg-subtle); font-size: .75rem; }
#mv-bc-demo .foot { padding: .55rem 1rem; border-top: 1px solid var(--mv-border); color: var(--mv-fg-muted); font-size: .75rem; min-height: 1.1rem; }
#mv-bc-demo .chart { display: flex; gap: .45rem; padding: 1rem 1rem .5rem; }
#mv-bc-demo .bar { flex: 1; display: grid; grid-template-rows: 7.5rem auto; justify-items: center; gap: .35rem; color: var(--mv-fg-subtle); font-size: .7rem; }
#mv-bc-demo .col { display: flex; align-items: flex-end; width: 100%; }
#mv-bc-demo .bar i { display: block; width: 100%; border-radius: var(--mv-radius-sm) var(--mv-radius-sm) 2px 2px; background: color-mix(in oklch, var(--mv-accent) 70%, var(--mv-surface)); transition: height var(--mv-duration-slow) var(--mv-ease-out); }
#mv-bc-demo .range { display: flex; gap: .25rem; padding: 0 1rem .75rem; }
</style>
<section class="panel" aria-labelledby="mv-bc-orders-title">
<div class="head">
<div><strong id="mv-bc-orders-title">Recent orders</strong><span id="mv-bc-orders-time">Updated 10:42 AM</span></div>
<button class="mv-button" data-variant="outline" data-size="sm" id="mv-bc-refresh">Refresh</button>
</div>
<mv-busy-cursor id="mv-bc-orders" dim hold-clicks>
<table>
<tbody>
<tr><td>#1042</td><td>Amara Okafor</td><td class="num">$184.00</td><td><button class="mv-button" data-variant="ghost" data-size="sm" data-refund>Refund</button></td></tr>
<tr><td>#1041</td><td>Mateo Rossi</td><td class="num">$62.50</td><td><button class="mv-button" data-variant="ghost" data-size="sm" data-refund>Refund</button></td></tr>
<tr><td>#1040</td><td>Yuki Tanaka</td><td class="num">$1,249.99</td><td><button class="mv-button" data-variant="ghost" data-size="sm" data-refund>Refund</button></td></tr>
<tr><td>#1039</td><td>Sofia Lindqvist</td><td class="num">$38.00</td><td><button class="mv-button" data-variant="ghost" data-size="sm" data-refund>Refund</button></td></tr>
</tbody>
</table>
</mv-busy-cursor>
<div class="foot" id="mv-bc-orders-log">Refresh, then click Refund while it loads: the click waits and runs once.</div>
</section>
<section class="panel" aria-labelledby="mv-bc-chart-title">
<div class="head">
<div><strong id="mv-bc-chart-title">Revenue by week</strong><span id="mv-bc-chart-range">Last 6 weeks · USD</span></div>
<button class="mv-button" data-variant="outline" data-size="sm" id="mv-bc-recompute">Recompute</button>
</div>
<mv-busy-cursor id="mv-bc-chart" hold-clicks="drop">
<div class="chart" id="mv-bc-bars"></div>
<div class="range" role="group" aria-label="Range">
<button class="mv-button" data-variant="secondary" data-size="sm" aria-pressed="true" data-weeks="6">6 weeks</button>
<button class="mv-button" data-variant="ghost" data-size="sm" aria-pressed="false" data-weeks="12">12 weeks</button>
</div>
</mv-busy-cursor>
<div class="foot">Real progress in the ring; clicks during the recompute are dropped with a shake.</div>
</section>
<script type="module">
const $ = (id) => document.getElementById(id);
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const time = new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit" });
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
const orders = $("mv-bc-orders");
const log = $("mv-bc-orders-log");
$("mv-bc-refresh").addEventListener("click", () => {
orders.track(wait(2200)).then(() => { $("mv-bc-orders-time").textContent = `Updated ${time.format(new Date())}`; });
});
orders.addEventListener("click", (e) => {
const button = e.target.closest("[data-refund]");
if (!button) return;
const row = button.closest("tr");
button.replaceWith(Object.assign(document.createElement("span"), { className: "done", textContent: "Refunded" }));
log.textContent = `Refund issued for ${row.cells[0].textContent} (${row.cells[1].textContent}).`;
});
orders.addEventListener("mv-click-held", () => { log.textContent = "Click held: it will run once the list is up to date."; });
const chart = $("mv-bc-chart");
const bars = $("mv-bc-bars");
const draw = (weeks) => {
const values = Array.from({ length: 6 }, (_, i) => 18000 + Math.round(Math.sin(i * 1.3 + weeks) * 6000 + i * 1500 * (weeks / 6)));
const max = Math.max(...values);
bars.replaceChildren(...values.map((v, i) => {
const bar = document.createElement("div");
bar.className = "bar";
bar.title = money.format(v);
const fill = document.createElement("i");
fill.style.height = `${Math.round((v / max) * 100)}%`;
const col = document.createElement("div");
col.className = "col";
col.append(fill);
bar.append(col, `W${i + 1}`);
return bar;
}));
};
const recompute = async (weeks) => {
if (chart.busy) return;
chart.progress = 0;
await chart.track(async () => {
for (let i = 1; i <= 10; i++) { await wait(260); chart.progress = i / 10; }
});
chart.progress = null;
draw(weeks);
};
let weeks = 6;
draw(weeks);
$("mv-bc-recompute").addEventListener("click", () => recompute(weeks));
chart.querySelectorAll("[data-weeks]").forEach((b) => b.addEventListener("click", () => {
weeks = Number(b.dataset.weeks);
chart.querySelectorAll("[data-weeks]").forEach((o) => {
o.setAttribute("aria-pressed", String(o === b));
o.dataset.variant = o === b ? "secondary" : "ghost";
});
$("mv-bc-chart-range").textContent = `Last ${weeks} weeks · USD`;
recompute(weeks);
}));
</script>
</div>API
Attributes
| Name | Tipo | Default | Description |
|---|---|---|---|
busy | boolean | The region is working. Also settable as el.busy = true, or managed by track(). | |
progress | number (0-1) | Real progress drawn as an arc in the ring. Absent or empty: indeterminate spin. | |
dim | boolean | Softly dims the region’s links, buttons and form controls while the ring is shown (they stay clickable). | |
hold-clicks | (empty) | replay | drop | Holds clicks on interactive elements while busy. Empty or replay: the last one runs once when ready (if its element still exists). drop: it is discarded and the ring shakes. Absent: clicks go through. | |
delay | number (ms) | 300 | How long the region must stay busy before the ring, cursor and announcement appear (no flicker on fast loads). A held click shows them at once. |
data-state | busy | Set by the component on itself while the busy state is shown (drives cursor: progress and dim). |
Properties
| Name | Tipo | Default | Description |
|---|---|---|---|
busy | boolean | Mirror of the attribute. | |
progress | number | null | Mirror of the attribute; null returns to indeterminate. | |
strings | { loading, done, wait } | { loading: "Loading…", done: "Done", wait: "Still loading. Try again in a moment." } | Announced texts, for translation or context (e.g. { loading: "Refreshing orders…", done: "Orders updated" }). wait is announced when a keyboard or assistive-tech activation is dropped. |
Methods
| Name | Description |
|---|---|
track(work) | Busy until work (a promise, or a function returning one) settles; overlapping calls stay busy until the last one. Returns a promise with the same outcome. |
Events
| Name | Description |
|---|---|
mv-busy-change | The busy state changed (immediately, not delayed); detail = { busy }. |
mv-click-held | A click is about to be held; detail = { target, mode }. Cancelable: preventDefault() lets this click through. |
mv-replay | A held click is about to run; detail = { target }. Cancelable: preventDefault() skips it. |
Content structure
| Name | Description |
|---|---|
(content) | The region: any content. The ring is added only while shown, and re-added if you replace the children. |
CSS classes
| Name | Description |
|---|---|
mv-busy-cursor-ring | The ring beside the pointer (aria-hidden, pointer-events: none). data-on while visible, data-determinate with progress, data-state="done", data-shake. |
mv-busy-cursor-svg / -track / -arc | Its SVG parts. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-busy-cursor-color | var(--mv-accent) | Arc color. |
--mv-busy-cursor-track | arc color at 20% | Track under the arc. |
--mv-busy-cursor-done | var(--mv-success) | Arc color of the short “done” finish. |
--mv-busy-cursor-held | var(--mv-warning) | Arc color while a dropped click shakes the ring. |
--mv-busy-cursor-size | 1.375rem | Ring diameter. |
--mv-busy-cursor-dim | 0.5 | Opacity of interactive parts with dim. |
Accessibility
The system cursor is never hidden or replaced: the ring sits beside it (offset, flipped near the region’s edges) and the region uses the standard cursor: progress, so OS cursor size and contrast settings keep working. The ring is decorative (aria-hidden, pointer-events: none) and drawn only under (hover: hover) and (pointer: fine); touch input is ignored. The region gets aria-busy="true" as soon as it is busy. If the wait lasts longer than the delay, one polite role=status message says “Loading…”, then “Done” when it ends; it is never repeated, progress changes are not announced, and the status region lives outside the busy subtree because screen readers may hold back live changes inside aria-busy. Keyboard and assistive-tech activations follow the same hold rules as pointer clicks; a dropped one announces “Still loading. Try again in a moment.” since the ring is not visible to them. All texts come from strings (English defaults). Held clicks are replayed with the element’s own click(), only if it is still in the page and not disabled. dim lowers opacity only, never removes interactivity or focus. Reduced motion (OS or data-motion="reduce"): no spin or shake, a static arc, and a held click only tints the ring. Forced colors: Canvas disc, Highlight arc, CanvasText outline. Removing the element clears its timers, listeners, ring and status region.