Exclusivobeta

Particle Counter <mv-particle-counter>

Um número de KPI desenhado como uma matriz de pontos feita de partículas: quando o valor muda, as partículas dos dígitos alterados se desfazem, voam e são reatribuídas aos pontos dos novos dígitos (conservadas: as que sobram se fundem, as que faltam se separam das outras), com um brilho verde na subida e um vermelho na descida. Formatos Intl (moeda, %, compacto), prefixo/sufixo; o texto real continua no DOM.

CategoriaTexto animado
TipoWeb Component (<mv-particle-counter>)
Statusbeta
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

Instalação

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

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

Arquivos copiados (dependências incluídas): 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

Marcação de referência, para usar como ponto de partida e personalizar com atributos, data-* e variáveis 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 foi traduzida com IA. Informar um problema de tradução