Exclusivobeta
Count Bump <mv-count-bump>
Envolva um contador de carrinho, um badge de notificações, um contador de não lidos ou de curtidas e cada mudança fica perceptível: um pequeno salto direcional (para cima quando aumenta, para baixo quando diminui), só os dígitos alterados rolam, um “+2” opcional sai flutuando e max="99" mostra “99+”. O detalhe que os outros deixam passar: nunca anima na primeira renderização, no primeiro valor que chega nem quando um framework renderiza de novo o mesmo valor, uma rajada de mudanças se funde em um único salto com um único delta líquido, os números passam por Intl, e o anúncio opcional para leitores de tela (“3 items in cart”, com plurais) espera o contador se estabilizar em vez de ler cada etapa.
| Categoria | Microinterações |
|---|---|
| Tipo | Web Component (<mv-count-bump>) |
| Status | beta |
| Keywords | exclusive, 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
Instalação
node scripts/add.mjs count-bump --out ./src/marvelousAgente de IA com o servidor MCP do Marvelous UI: install_components({ slugs: ["count-bump"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Arquivos copiados (dependências incluídas): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, components/count-bump/count-bump.js, components/count-bump/count-bump.css.
Uso
Início rápido, a menor marcação que funciona:
<mv-count-bump value="3">3</mv-count-bump>Marcação de referência, para usar como ponto de partida e personalizar com atributos, data-* e variáveis 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
| Name | Tipo | Default | Description |
|---|---|---|---|
value | number | The count. Changing it plays the bump; setting the same value again does nothing. Without it, the number is read from the text inside. | |
max | number | Above this, the visible text becomes strings.overflow (“99+” for max="99"); announcements keep the real count. | |
delta | boolean | false | Show the net change (“+2”, “-1”) floating off the number; a burst shows one cumulative delta. |
label | string | Turns on a polite announcement; {count} is replaced by the formatted count, e.g. “{count} items in cart”. For plurals, use strings.label instead. | |
locale | BCP 47 tag | en-US | Locale for number formatting and plural rules. |
hide-zero | boolean | false | Hide the number or badge while the value is 0 (the live region stays, so “0 items” can still be announced). |
data-dir | up | down | Set by the component: direction of the last bump (for styling). | |
data-zero / data-empty | boolean | Set by the component when the value is 0 / missing. |
Properties
| Name | Tipo | Default | Description |
|---|---|---|---|
value | number | Same as the attribute; changes made in the same frame are merged. | |
format | Intl.NumberFormatOptions | Formatting 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
| Name | Description |
|---|---|
jump(value) | Show a value with no animation and no announcement (initial data load, sync, reset). |
Events
| Name | Description |
|---|---|
mv-bump | Before 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
| Name | Description |
|---|---|
(text) | The number itself, so it reads correctly before JavaScript loads. |
first child element | Or 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
| Name | Description |
|---|---|
mv-count-bump-target | Added 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 / -live | Generated parts (digits and delta are aria-hidden; -live is the polite status region). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-count-bump-duration | var(--mv-duration-slow) | Digit roll duration. |
--mv-count-bump-ease | cubic-bezier(0.3, 1.3, 0.5, 1) | Digit roll easing (a small overshoot). |
--mv-count-bump-lift | 0.12 | Pop amplitude (scale added on increase). |
--mv-count-bump-up | success mixed toward --mv-fg | Color of a positive delta. |
--mv-count-bump-down | var(--mv-fg-muted) | Color of a negative delta. |
--mv-count-bump-highlight | var(--mv-accent) | Highlight ring color under reduced motion. |
--mv-count-bump-delta-duration | 950ms | How long the delta stays visible. |
--mv-count-bump-flash-duration | 900ms | Reduced-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.