Exclusifbêta

Count Bump <mv-count-bump>

Enveloppez un compteur de panier, un badge de notification, un compteur de non-lus ou de j’aime, et chaque changement devient visible : un petit rebond directionnel (vers le haut quand ça augmente, vers le bas quand ça diminue), seuls les chiffres modifiés défilent, un « +2 » facultatif s’envole et max="99" affiche « 99+ ». Le détail que les autres oublient : il ne s’anime jamais au premier rendu, à l’arrivée de la première valeur ni quand un framework refait le rendu de la même valeur, une rafale de changements se fond en un seul rebond avec un seul écart net, les nombres passent par Intl, et l’annonce facultative pour les lecteurs d’écran (« 3 articles dans le panier », avec gestion du pluriel) attend que le compteur se stabilise au lieu de lire chaque étape.

CatégorieMicro-interactions
TypeWeb Component (<mv-count-bump>)
Statutbêta
Keywordsexclusive, light, counter, badge, notification, cart, unread, likes, odometer, live-region, intl

When to use

  • A cart icon badge should visibly react when an item is added from elsewhere on the page
  • A notification or unread badge updates in the background and users miss that it changed
  • A like, vote or follower count updates live and should show the change without a jarring re-render
  • A counter set by a framework must not animate on hydration or when the same value is rendered again

Avoid when

  • A large headline statistic should count up from zero when it scrolls into view → use Number Ticker instead
  • The count belongs to a like or favorite toggle the user presses → use Like Button instead
  • The user edits the number directly with steppers or typing → use Number Field instead

Installation

node scripts/add.mjs count-bump --out ./src/marvelous

Agent IA avec le serveur MCP de Marvelous UI : install_components({ slugs: ["count-bump"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Fichiers copiés (dépendances comprises) : tokens/tokens.css, core/base.css, core/dom.js, core/element.js, components/count-bump/count-bump.js, components/count-bump/count-bump.css.

Utilisation

Démarrage rapide, le balisage minimal qui fonctionne :

<mv-count-bump value="3">3</mv-count-bump>

Balisage de référence : partez de celui-ci et personnalisez-le avec les attributs, data-* et les variables CSS :

<div id="mv-cb-demo" style="display:grid;gap:1rem;width:100%;max-width:520px">
  <section style="border:1px solid var(--mv-border);border-radius:var(--mv-radius-xl);background:var(--mv-surface);box-shadow:var(--mv-shadow-sm)">
    <header style="display:flex;align-items:center;gap:.75rem;padding:1.1rem 1rem .8rem;border-bottom:1px solid var(--mv-border)">
      <strong style="flex:1;font-size:.95rem;letter-spacing:-.01em">Lumen Supply</strong>
      <span class="mv-badge-anchor">
        <button class="mv-button" data-variant="ghost" data-size="icon" aria-label="Notifications" aria-describedby="mv-cb-bell-count">
          <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8a6 6 0 1 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
        </button>
        <mv-count-bump id="mv-cb-bell" value="97" max="99" hide-zero>
          <span class="mv-badge" id="mv-cb-bell-count" data-variant="danger" data-appearance="solid" data-position="top-right" style="--mv-badge-offset:.3rem">97</span>
        </mv-count-bump>
      </span>
      <span class="mv-badge-anchor">
        <button class="mv-button" data-variant="ghost" data-size="icon" aria-label="Cart" aria-describedby="mv-cb-cart-count">
          <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="9" cy="20" r="1.2"/><circle cx="18" cy="20" r="1.2"/><path d="M2.5 3.5h2.6l2.3 11.2a1.6 1.6 0 0 0 1.6 1.3h8.6a1.6 1.6 0 0 0 1.6-1.2L21 7.5H6"/></svg>
        </button>
        <mv-count-bump id="mv-cb-cart" value="2" delta hide-zero>
          <span class="mv-badge" id="mv-cb-cart-count" data-position="top-right" style="--mv-badge-offset:.3rem">2</span>
        </mv-count-bump>
      </span>
    </header>
    <div style="display:flex;align-items:center;gap:.9rem;padding:1rem">
      <div aria-hidden="true" style="width:3.25rem;height:3.25rem;border-radius:var(--mv-radius-lg);background:linear-gradient(135deg,oklch(0.72 0.09 40),oklch(0.5 0.1 20))"></div>
      <div style="flex:1;display:grid;gap:.15rem">
        <span style="font-weight:600;font-size:.9rem">Merino Beanie</span>
        <span style="font-size:.8rem;color:var(--mv-fg-muted)">Rust · $38.00</span>
      </div>
      <button class="mv-button" data-size="sm" data-cb="add">Add to cart</button>
    </div>
    <div style="display:flex;flex-wrap:wrap;gap:.4rem;padding:0 1rem 1rem">
      <button class="mv-button" data-variant="outline" data-size="sm" data-cb="add3">Add 3</button>
      <button class="mv-button" data-variant="outline" data-size="sm" data-cb="remove">Remove one</button>
      <button class="mv-button" data-variant="outline" data-size="sm" data-cb="notify">New notification</button>
      <button class="mv-button" data-variant="ghost" data-size="sm" data-cb="clear">Mark all read</button>
    </div>
  </section>

  <section style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;padding:.8rem 1rem;border:1px solid var(--mv-border);border-radius:var(--mv-radius-xl);background:var(--mv-surface)">
    <span style="flex:1;min-width:10rem;font-size:.9rem;color:var(--mv-fg-muted)">
      <mv-count-bump id="mv-cb-likes" value="1284" delta label="{count} likes" style="font-weight:600;color:var(--mv-fg)">1,284</mv-count-bump> likes
    </span>
    <button class="mv-button" data-variant="secondary" data-size="sm" data-cb="burst">5 quick likes</button>
    <button class="mv-button" data-variant="ghost" data-size="sm" data-cb="same">Re-render ×10</button>
  </section>
</div>

<script type="module">
  const root = document.getElementById("mv-cb-demo");
  const cart = root.querySelector("#mv-cb-cart");
  const bell = root.querySelector("#mv-cb-bell");
  const likes = root.querySelector("#mv-cb-likes");
  cart.strings = { label: { one: "{count} item in cart", other: "{count} items in cart" } };
  bell.strings = { label: { one: "{count} unread notification", other: "{count} unread notifications" } };
  const actions = {
    add: () => cart.value += 1,
    add3: () => cart.value += 3,
    remove: () => cart.value = Math.max(0, cart.value - 1),
    notify: () => bell.value += 1,
    clear: () => bell.value = 0,
    // Five changes in quick succession: one bump, one "+5".
    burst: () => { for (let i = 1; i <= 5; i++) setTimeout(() => likes.value += 1, i * 90); },
    // A framework re-rendering with the same value: nothing plays.
    same: () => { for (let i = 0; i < 10; i++) likes.value = likes.value; },
  };
  root.addEventListener("click", (e) => actions[e.target.closest("[data-cb]")?.dataset.cb]?.());
</script>

API

Attributes

NameTypeDefaultDescription
valuenumberThe count. Changing it plays the bump; setting the same value again does nothing. Without it, the number is read from the text inside.
maxnumberAbove this, the visible text becomes strings.overflow (“99+” for max="99"); announcements keep the real count.
deltabooleanfalseShow the net change (“+2”, “-1”) floating off the number; a burst shows one cumulative delta.
labelstringTurns on a polite announcement; {count} is replaced by the formatted count, e.g. “{count} items in cart”. For plurals, use strings.label instead.
localeBCP 47 tagen-USLocale for number formatting and plural rules.
hide-zerobooleanfalseHide the number or badge while the value is 0 (the live region stays, so “0 items” can still be announced).
data-dirup | downSet by the component: direction of the last bump (for styling).
data-zero / data-emptybooleanSet by the component when the value is 0 / missing.

Properties

NameTypeDefaultDescription
valuenumberSame as the attribute; changes made in the same frame are merged.
formatIntl.NumberFormatOptionsFormatting options, e.g. { notation: "compact" } for “1.2K” or { style: "currency", currency: "USD" }.
strings{ label, overflow }{ label: "", overflow: "{max}+" }Translatable text. label is a template or an object keyed by plural category ({ one: "{count} item in cart", other: "{count} items in cart" }); the label attribute takes precedence.

Methods

NameDescription
jump(value)Show a value with no animation and no announcement (initial data load, sync, reset).

Events

NameDescription
mv-bumpBefore each animated update (during a burst, once per change with the cumulative delta); detail = { value, previous, delta }. Cancelable: preventDefault() updates the number silently.

Content structure

NameDescription
(text)The number itself, so it reads correctly before JavaScript loads.
first child elementOr wrap a badge (e.g. .mv-badge): only its text holding the number is replaced, icons are kept, and the whole badge pops.

CSS classes

NameDescription
mv-count-bump-targetAdded to the wrapped element (or the generated .mv-count-bump-value); carries data-bump while popping.
mv-count-bump-num / -digits / -cell / -in / -out / -delta / -liveGenerated parts (digits and delta are aria-hidden; -live is the polite status region).

CSS variables

NameDefaultDescription
--mv-count-bump-durationvar(--mv-duration-slow)Digit roll duration.
--mv-count-bump-easecubic-bezier(0.3, 1.3, 0.5, 1)Digit roll easing (a small overshoot).
--mv-count-bump-lift0.12Pop amplitude (scale added on increase).
--mv-count-bump-upsuccess mixed toward --mv-fgColor of a positive delta.
--mv-count-bump-downvar(--mv-fg-muted)Color of a negative delta.
--mv-count-bump-highlightvar(--mv-accent)Highlight ring color under reduced motion.
--mv-count-bump-delta-duration950msHow long the delta stays visible.
--mv-count-bump-flash-duration900msReduced-motion highlight duration.

Accessibility

Screen readers read one clean number: a visually hidden copy of the formatted value, while the rolling digits and the delta are aria-hidden (no doubled or half-rolled digits). Announcements are opt-in (label or strings.label) through a polite role=status region: never on first render or on silent updates, debounced until the count has been still for 0.7 s (at most one every 2.5 s during a long burst), plural-aware with Intl.PluralRules, the real count even past max, duplicates skipped, and the message cleared after 4 s so it never lingers. Keep the component next to a button rather than inside it (reference it with aria-describedby, as in the demo), since live regions inside buttons are unreliable. Nothing is focusable or interactive, so there is no keyboard trap. Reduced motion (OS setting or data-motion="reduce"): no roll, pop or float; the number swaps instantly and a brief highlight ring marks the change. Forced colors: the ring uses Highlight and the delta CanvasText. SSR-safe: the number is plain text in the markup before JavaScript runs.

Cette page a été traduite par IA. Signaler un problème de traduction