Exclusivobeta

Particle Counter <mv-particle-counter>

Una cifra KPI dibujada como una matriz de puntos hecha de partículas: cuando cambia el valor, las partículas de los dígitos modificados se separan, vuelan y se reasignan a los puntos de los nuevos dígitos (se conservan: las sobrantes se fusionan, las que faltan se separan de las demás), con un destello verde al subir y uno rojo al bajar. Formatos Intl (moneda, %, compacto), prefijo/sufijo; el texto real se queda en el DOM.

CategoríaTexto animado
TipoWeb Component (<mv-particle-counter>)
Estadobeta
Keywordsexclusive, 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-en d devices; eac h one runs its own canvas particle syste min

Instalación

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

Agente de IA con el servidor MCP de Marvelous UI: install_components({ slugs: ["particle-counter"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Archivos copiados (dependencias incluidas): 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.

Uso

Marcado de referencia: parte de él y personalízalo con atributos, data-* y variables CSS:

<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

NameTipoDefaultDescription
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

NameTipoDescription
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.

Esta página se tradujo con IA. Informar de un problema de traducción