Exclusivobeta
Busy Cursor <mv-busy-cursor>
“Aguarde” local para uma região (uma tabela recarregando, um gráfico sendo recalculado, um painel salvando): enquanto ela está ocupada, um anel minúsculo acompanha o cursor do sistema com progresso indeterminado ou real, a região recebe cursor: progress e aria-busy, e os cliques podem ser retidos e depois reproduzidos uma vez ou descartados com uma sacudida. O que os outros deixam passar: só aparece em esperas de mais de 300 ms, nunca substitui o cursor e anuncia um único “Loading…” / “Done” em modo polite de fora da subárvore aria-busy, onde os leitores de tela o segurariam.
| Categoria | Cursores |
|---|---|
| Tipo | Web Component (<mv-busy-cursor>) |
| Status | 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
Instalação
node scripts/add.mjs busy-cursor --out ./src/marvelousAgente de IA com o servidor MCP do Marvelous UI: install_components({ slugs: ["busy-cursor"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Arquivos copiados (dependências incluídas): 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
Início rápido, a menor marcação que funciona:
<mv-busy-cursor id="orders"> …your table… </mv-busy-cursor>
<!-- then: orders.track(fetch("/api/orders")) or orders.busy = true -->Marcação de referência, para usar como ponto de partida e personalizar com atributos, data-* e variáveis 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.