Exclusivebeta

Count Bump <mv-count-bump>

Wrap a cart count, notification badge, unread counter or like count and every change becomes noticeable: a tiny directional bump (up for more, down for less), only the changed digits roll, an optional “+2” floats off and max="99" shows “99+”. The detail others miss: it never animates on first render, on the first value to arrive or when a framework re-renders the same value, a burst of changes merges into one bump with one net delta, numbers go through Intl, and the optional screen reader announcement (“3 items in cart”, plural-aware) waits for the count to settle instead of reading every step.

CategoryMicro-interactions
TypeWeb Component (<mv-count-bump>)
Statusbeta
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

Install

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

AI agent with the Marvelous UI MCP server: install_components({ slugs: ["count-bump"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).

Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, components/count-bump/count-bump.js, components/count-bump/count-bump.css.

Usage

Quick start, the smallest working markup:

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

Canonical markup, to start from and customize with attributes, data-* and CSS variables:

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