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.

CategoryAnimated text
TypeWeb Component (<mv-particle-counter>)
Statusbeta
Keywordsexclusive, kpi, counter, particles, canvas, dashboard, stats, dot-matrix, number, intl, live

When to use

Avoid when

Install

node scripts/add.mjs particle-counter --out ./src/marvelous

AI 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

NameTypeDefaultDescription
valuenumber0 (or the initial text)Displayed value. Changing it (attribute or property) triggers the particle reassignment.
formatnumber | currency | percent | compactnumberIntl.NumberFormat style. percent expects percentage points (3.4 → 3.4%).
currencyISO 4217 codeUSDCurrency for format=currency.
localeBCP 47lang of the closest ancestorFormatting locale (separators, symbol).
decimalsnumberFixed number of decimals.
prefix / suffixstringText added before / after (e.g. “+”, “visitors”), also rendered as particles.
gapnumberauto (≈ size / 26)Particle grid pitch (px); smaller = denser.
interactivebooleanParticles move away from the cursor, then return.
invertbooleanFor metrics where down is good (bounce rate, latency): increases flash red, decreases green.
announcenumber | off4000Minimum interval (ms) between two screen reader announcements; off to announce nothing.

Properties

NameTypeDescription
valuenumberValue (reflected to the attribute).
formatter(value) => stringCustom formatting, takes precedence over format.
textstringRead-only: the formatted text displayed.

Events

NameDescription
mv-changedetail: { value, previous, direction (1 | -1 | 0), text }.

CSS classes

NameDescription
mv-particle-counter-textReal text (transparent once the canvas is ready): it carries size, selection and copy.
mv-particle-counter-canvasCanvas overflowing 32 px around the text.

CSS variables

NameDefaultDescription
--mv-particle-counter-colorcurrentColorParticle color at rest.
--mv-particle-counter-upvar(--mv-success)Flash on increase.
--mv-particle-counter-downvar(--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.