Exclusivebeta
Particle Counter — <mv-particle-counter>
A KPI number drawn as a dot matrix of particles: when the value changes, the particles of the changed digits break apart, fly and are reassigned to the dots of the new digits (conserved: extras merge, missing ones split off from the others), with a green flash on the way up and a red one on the way down. Intl formats (currency, %, compact), prefix/suffix; the real text stays in the DOM.
| Category | Animated text |
|---|---|
| Type | Web Component (<mv-particle-counter>) |
| Status | beta |
| Keywords | exclusive, kpi, counter, particles, canvas, dashboard, stats, dot-matrix, number, intl, live |
When to use
- A hero KPI on a live dashboard should visibly react every time its value changes
- Rises and drops need a green or red flash, invertible for metrics where down is good
- Screen reader announcements of a frequently updating value must be throttled
Avoid when
- A simple count-up or odometer is enough, or many numbers animate on the same screen → use Number Ticker instead
- Many instances per page or low-end devices; each one runs its own canvas particle system
Install
node scripts/add.mjs particle-counter --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["particle-counter"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Files copied (dependencies included): tokens/tokens.css, core/base.css, core/canvas.js, core/dom.js, core/element.js, core/motion.js, core/observe.js, components/particle-counter/particle-counter.js, components/particle-counter/particle-counter.css.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<div id="pc-demo" class="pc-dash" lang="en-US">
<style>
.pc-dash { width: min(100%, 60rem); margin-inline: auto; display: grid; gap: var(--mv-space-4); font-family: var(--mv-font-sans); }
.pc-dash-head { display: flex; align-items: center; justify-content: space-between; gap: var(--mv-space-3); flex-wrap: wrap; }
.pc-dash-head h3 { margin: 0; font-size: var(--mv-text-base); font-weight: var(--mv-weight-semibold); }
.pc-dash-head p { margin: 0; font-size: var(--mv-text-sm); color: var(--mv-fg-muted); display: flex; align-items: center; gap: .5rem; }
.pc-live-dot { width: .5rem; height: .5rem; border-radius: 50%; background: var(--mv-success); box-shadow: 0 0 0 0 color-mix(in oklch, var(--mv-success) 50%, transparent); animation: pc-live 2s var(--mv-ease-out) infinite; }
.pc-dash[data-paused] .pc-live-dot { background: var(--mv-fg-subtle); animation: none; }
@keyframes pc-live { 70% { box-shadow: 0 0 0 .45rem transparent; } 100% { box-shadow: 0 0 0 0 transparent; } }
@media (prefers-reduced-motion: reduce) { .pc-live-dot { animation: none; } }
.pc-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(13.5rem, 1fr)); gap: var(--mv-space-4); }
.pc-tile { background: var(--mv-surface); border: 1px solid var(--mv-border); border-radius: var(--mv-radius-xl); padding: var(--mv-space-5); display: grid; gap: var(--mv-space-2); align-content: start; min-width: 0; box-shadow: var(--mv-shadow-xs); }
.pc-tile[data-hero] { grid-column: 1 / -1; grid-template-columns: 1fr auto; align-items: end; padding: var(--mv-space-6); }
.pc-label { margin: 0; font-size: var(--mv-text-sm); color: var(--mv-fg-muted); }
.pc-value { font-size: 2.25rem; font-weight: var(--mv-weight-bold); letter-spacing: -0.01em; color: var(--mv-fg); }
.pc-tile[data-hero] .pc-value { font-size: clamp(3rem, 2rem + 4vw, 4.75rem); font-weight: 800; letter-spacing: -0.02em; }
.pc-delta { margin: 0; font-size: var(--mv-text-xs); color: var(--mv-fg-muted); display: flex; gap: .375rem; align-items: center; }
.pc-delta b { font-weight: var(--mv-weight-semibold); color: var(--mv-fg); display: inline-flex; align-items: center; gap: .2rem; }
.pc-delta b::before { content: ""; width: .45rem; height: .45rem; border-radius: 1px; background: var(--_c, var(--mv-fg-subtle)); clip-path: polygon(50% 0, 100% 100%, 0 100%); }
.pc-delta[data-dir="down"] b::before { clip-path: polygon(0 0, 100% 0, 50% 100%); }
.pc-delta[data-good] { --_c: var(--mv-success); }
.pc-delta[data-bad] { --_c: var(--mv-danger); }
.pc-spark { display: flex; align-items: end; gap: 3px; height: 3.25rem; }
.pc-spark i { width: 6px; border-radius: 2px 2px 0 0; background: color-mix(in oklch, var(--mv-fg) 14%, transparent); height: calc(var(--v) * 1%); transition: height var(--mv-duration-slow) var(--mv-ease-out); }
.pc-spark i:last-child { background: var(--mv-accent); }
</style>
<div class="pc-dash-head">
<div>
<h3>Lumen Store — today</h3>
<p><span class="pc-live-dot" aria-hidden="true"></span><span data-status>Live updates</span></p>
</div>
<button class="mv-button" data-variant="outline" data-size="sm" type="button" data-toggle aria-pressed="false">Pause</button>
</div>
<div class="pc-grid">
<section class="pc-tile" data-hero aria-label="Revenue">
<div style="display:grid;gap:var(--mv-space-2);min-width:0">
<p class="pc-label">Revenue</p>
<mv-particle-counter class="pc-value" data-kpi="revenue" value="48213.5" format="currency" decimals="2" interactive></mv-particle-counter>
<p class="pc-delta" data-good data-dir="up"><b>+12.4%</b> vs. this time yesterday</p>
</div>
<div class="pc-spark" aria-hidden="true" data-spark></div>
</section>
<section class="pc-tile" aria-label="Visitors online">
<p class="pc-label">Visitors online</p>
<mv-particle-counter class="pc-value" data-kpi="visitors" value="1284"></mv-particle-counter>
<p class="pc-delta" data-dir="up" data-good><b>+86</b> in the last hour</p>
</section>
<section class="pc-tile" aria-label="Conversion rate">
<p class="pc-label">Conversion rate</p>
<mv-particle-counter class="pc-value" data-kpi="conversion" value="3.4" format="percent" decimals="1"></mv-particle-counter>
<p class="pc-delta" data-dir="up" data-good><b>+0.3 pts</b> vs. yesterday</p>
</section>
<section class="pc-tile" aria-label="Bounce rate">
<p class="pc-label">Bounce rate</p>
<mv-particle-counter class="pc-value" data-kpi="bounce" value="41.2" format="percent" decimals="1" invert></mv-particle-counter>
<p class="pc-delta" data-dir="down" data-good><b>−1.8 pts</b> down is good</p>
</section>
</div>
</div>
<script type="module">
const root = document.getElementById("pc-demo");
await customElements.whenDefined("mv-particle-counter");
const kpi = (k) => root.querySelector(`[data-kpi="${k}"]`);
const spark = root.querySelector("[data-spark]");
const bars = [38, 44, 41, 52, 49, 58, 61, 57, 66, 70, 74, 80];
const drawSpark = () => spark.replaceChildren(...bars.map((v) => Object.assign(document.createElement("i"), { style: `--v:${v}` })));
drawSpark();
const rnd = (a, b) => a + Math.random() * (b - a);
let n = 0;
const tick = () => {
n++;
const rev = kpi("revenue");
rev.value = Math.round((rev.value + rnd(18, 420)) * 100) / 100;
bars[bars.length - 1] = Math.min(100, bars[bars.length - 1] + rnd(0.5, 3));
drawSpark();
const vis = kpi("visitors");
vis.value = Math.max(900, Math.round(vis.value + rnd(-38, 46)));
if (n % 2 === 0) { const c = kpi("conversion"); c.value = Math.round((c.value + rnd(-0.2, 0.25)) * 10) / 10; }
if (n % 3 === 0) { const b = kpi("bounce"); b.value = Math.round((b.value + rnd(-0.6, 0.5)) * 10) / 10; }
};
let timer = setInterval(tick, 2200);
const btn = root.querySelector("[data-toggle]");
btn.addEventListener("click", () => {
const paused = btn.getAttribute("aria-pressed") !== "true";
btn.setAttribute("aria-pressed", String(paused));
btn.textContent = paused ? "Resume" : "Pause";
root.querySelector("[data-status]").textContent = paused ? "Paused" : "Live updates";
root.toggleAttribute("data-paused", paused);
clearInterval(timer);
if (!paused) timer = setInterval(tick, 2200);
});
// Stop when the demo is removed (showcase re-renders).
const mo = new MutationObserver(() => { if (!root.isConnected) { clearInterval(timer); mo.disconnect(); } });
mo.observe(document.body, { childList: true, subtree: true });
</script>API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
value | number | 0 (or the initial text) | Displayed value. Changing it (attribute or property) triggers the particle reassignment. |
format | number | currency | percent | compact | number | Intl.NumberFormat style. percent expects percentage points (3.4 → 3.4%). |
currency | ISO 4217 code | USD | Currency for format=currency. |
locale | BCP 47 | lang of the closest ancestor | Formatting locale (separators, symbol). |
decimals | number | Fixed number of decimals. | |
prefix / suffix | string | Text added before / after (e.g. “+”, “visitors”), also rendered as particles. | |
gap | number | auto (≈ size / 26) | Particle grid pitch (px); smaller = denser. |
interactive | boolean | Particles move away from the cursor, then return. | |
invert | boolean | For metrics where down is good (bounce rate, latency): increases flash red, decreases green. | |
announce | number | off | 4000 | Minimum interval (ms) between two screen reader announcements; off to announce nothing. |
Properties
| Name | Type | Description |
|---|---|---|
value | number | Value (reflected to the attribute). |
formatter | (value) => string | Custom formatting, takes precedence over format. |
text | string | Read-only: the formatted text displayed. |
Events
| Name | Description |
|---|---|
mv-change | detail: { value, previous, direction (1 | -1 | 0), text }. |
CSS classes
| Name | Description |
|---|---|
mv-particle-counter-text | Real text (transparent once the canvas is ready): it carries size, selection and copy. |
mv-particle-counter-canvas | Canvas overflowing 32 px around the text. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-particle-counter-color | currentColor | Particle color at rest. |
--mv-particle-counter-up | var(--mv-success) | Flash on increase. |
--mv-particle-counter-down | var(--mv-danger) | Flash on decrease. |
Accessibility
The real formatted text stays in the DOM (transparent): it is read, selectable and copyable; the canvas is aria-hidden. A polite live region announces the new value at most once every 4 s (announce), so a live dashboard doesn’t flood screen readers. Size and font follow the CSS (font-size, font-weight), tabular digits by default. Reduced motion: particles snap into place, no flight or flash. Forced colors: the real text reappears and the canvas is hidden. The animation loop stops as soon as every particle has landed.