Counter
A compact numeric count pill — notification totals and unread counts, with a 99+ overflow cap and an animated digit roll.
@garn/ui/counterOn this page
Default
A danger-tone notification count.
Tone × appearance
Every tone rendered against every appearance. Hover a cell for the data attributes it emits; click the copy icon for that combination's JSX.
Shapes
sharp · rounded · pill, shown with solid colors.
Sizes
sm · md.
Overflow
The 99+ cap and compact (1.2K) abbreviation.
Pulse
A reduced-motion-safe activity halo.
Owner
Labelled by its owner — the button reads 'Inbox, 4'.
Live
Increment/decrement to watch the digit roll (instant under reduced motion).
Indicator
Counter.Indicator — a count anchored onto a notification bell.
Indicator Dot
Counter.Indicator — a presence dot on an avatar (overlap='circular').
Indicator Positions
Counter.Indicator — the corner positions.
Installation
garn is copy-in — the garn CLI writes this component's source into your project, so you own and can edit it. It resolves any base files and installs the npm dependencies below for you.
npx garn-ui add counter- Registry
counter- Deps
class-variance-authority- Registry deps
utils
Import
import { Counter.Indicator } from "@garn/ui/counter";Anatomy
The parts this component is built from — compose them to assemble it.
Counter.Indicator[data-slot="counter-indicator"]A wrapper that anchors a marker (a bare dot, or a composed Counter) onto a child target — the notification-bubble / presence-dot role. Logical, RTL-safe positioning; hides at zero.
Props
The component's public props and their types.
appearance- Description
- No description.
- Type
"soft" | "solid"- Default
solid
shape- Description
- No description.
- Type
"pill" | "rounded" | "sharp"- Default
pill
size- Description
- No description.
- Type
"md" | "sm"- Default
md
tone- Description
- No description.
- Type
"brand" | "danger" | "discovery" | "info" | "neutral" | "success" | "warning"- Default
neutral
format- Description
- `"compact"` abbreviates large counts (`1.2K`) via Intl; `max` no longer applies.
- Type
"compact" | "standard"- Default
"standard"
live- Description
- Announce changes to assistive tech via an aria-live region.
- Type
boolean- Default
true
locale- Description
- BCP-47 locale for number formatting (pass it for SSR determinism).
- Type
string
max- Description
- Cap; counts above render `${max}+` (e.g. `99+`).
- Type
number- Default
99
pulse- Description
- Add a reduced-motion-safe "new activity" halo.
- Type
boolean- Default
false
showZero- Description
- Render a `0` instead of hiding the counter.
- Type
boolean- Default
false
valueRequired- Description
- The count to display. `0` renders nothing unless `showZero`.
- Type
number
Plus 279 inherited native <span> attributes.
Counter.Indicator
appearance- Description
- Count-marker appearance.
- Type
"soft" | "solid"- Default
"solid"
childrenRequired- Description
- The target the marker is anchored onto.
- Type
React.ReactNode
count- Description
- Show a count marker (a composed Counter). Hidden at 0 unless `showZero`.
- Type
number
dot- Description
- Show a bare presence dot instead of a number.
- Type
boolean- Default
false
format- Description
- Forwarded to the count marker.
- Type
"compact" | "standard"- Default
"standard"
invisible- Description
- Hide the marker while keeping the child in place.
- Type
boolean- Default
false
live- Description
- Announce count changes via the count marker's live region.
- Type
boolean- Default
true
locale- Description
- Forwarded to the count marker (SSR-deterministic formatting).
- Type
string
max- Description
- Forwarded to the count marker.
- Type
number- Default
99
offset- Description
- Nudge the marker inward by N px (fine-tuning against the target's radius).
- Type
number- Default
0
overlap- Description
- Inset the marker inward for a round target (an avatar).
- Type
"circular" | "rectangular"- Default
"rectangular"
position- Description
- Corner to anchor the marker on (logical / RTL-safe).
- Type
IndicatorPosition- Default
"top-end"
pulse- Description
- A reduced-motion-safe "new activity" halo on the marker.
- Type
boolean- Default
false
shape- Description
- Count-marker shape.
- Type
"pill" | "rounded" | "sharp"- Default
"pill"
showZero- Description
- Render a `0` count instead of hiding the marker.
- Type
boolean- Default
false
size- Description
- Marker size (shared with Counter).
- Type
"md" | "sm"- Default
"md"
tone- Description
- Tone of the marker (shared with Counter).
- Type
CounterTone- Default
"neutral"
withBorder- Description
- A ring in the surface color so the marker separates from the target.
- Type
boolean- Default
true
Plus 278 inherited native HTML attributes.
Styling
Target these data-slots and states, and remap these tokens, to restyle without forking the component.
[data-slot="counter"][data-slot="counter-value"][data-slot="counter-digit"][data-slot="counter-label"]States
| Selector | State | Description |
|---|---|---|
| rolling | Digits roll on a value change; an instant swap under prefers-reduced-motion. |
[data-pulse] | pulse | A reduced-motion-safe activity halo for new/live counts. |
| hidden-at-zero | value=0 renders nothing unless showZero — keeps a 0 count from being visual noise. |
| Property | Token | Tier |
|---|---|---|
fill | --garn-badge-danger-boldbrandable | semantic |
Private instance vars (never externalize): --counter-ping
When to use
Show a numeric total beside an owner — a notification/unread count on an icon button or nav item, a tally on a tab or list. Use Counter.Indicator to anchor a count or a presence dot onto the corner of a child (a bell icon, an avatar).
Reach for something else when
- A short text label or status (use Badge)
- a removable or selectable chip (use Tag)
- a single pick-to-change value (use BadgeSelect)
- determinate task progress (use Progress).
Overview
Counter shows a numeric total beside an owner — an unread count on a bell icon or nav item, a tally on a tab or list. It handles the niceties: a 99+ overflow cap, digit-roll animation, and a true-count announcement for assistive tech. Counter.Indicator anchors a count (or a bare presence dot) onto the corner of a child like an icon button or avatar. For a labelled status use Badge; for determinate progress use Progress.
Guidelines
Use a counter for a live tally, not a static label. It's for counts that change (notifications, items in a cart); a fixed category label is a Badge. Cap large numbers (99+) so the chip stays small and stable.
Announce the real count, hide zero. Let the true number reach screen readers even when the visual is capped, and don't render a "0" badge — absence is the clearer signal that there's nothing new.
Anchor it without obscuring its owner. When pinning a count to an icon or avatar via Indicator, keep the icon's own accessible name intact and make sure the badge doesn't cover the part that identifies it. A bare dot is enough when the exact number doesn't matter.
Best practices
- Label the owner so the number has context ('Inbox' + Counter → 'Inbox, 4').
- Use tone for intent — danger for alerts/notifications, brand for on-brand totals, neutral for generic counts.
- Let it hide at zero (the default); reach for showZero only when a 0 is meaningful.
- For overlays, wrap the target in Counter.Indicator; use overlap='circular' on round targets (avatars) and a dot for presence without a number.
- Don't put non-numeric text in a Counter — that's Badge.
- Don't rely on the capped '99+' for screen readers — it already announces the real count, but keep the owner labelled.
- Don't set live={false} on a count users need to hear change (e.g. a cart total).
Content guidelines
- Pass the raw number — Counter handles capping ('99+'), abbreviation ('1.2K'), and locale grouping.
- Keep max at the default 99 unless a larger cap reads better in context; switch to format='compact' for counts that routinely exceed thousands.
Troubleshooting
A Counter with value={0} renders nothing.
Cause. Counter hides at zero by default to avoid a noisy 0 bubble.
Fix. Pass showZero when a 0 is meaningful.
Screen reader hears '99+' or '1.2K' instead of the real number.
Cause. It does not — the visual is aria-hidden and the true count is in an sr-only live region. This is the intended split.
Fix. Nothing to fix; label the owner for context, or set aria-label to customize the announced name.
Editing dark-mode counter colors in the component has no effect.
Cause. Tones resolve through the --garn-badge-* component tokens; the dark values live in the token layer.
Fix. Adjust dark mode in the garn theme CSS (.dark block) — never with raw ramps in counter.tsx.
A Counter.Indicator dot/count sits in the wrong place or off the corner of a round avatar.
Cause. Default position is top-end (rectangular); a round target needs the marker pulled inward.
Fix. Set overlap='circular' for avatars, pick a position ('bottom-end' for presence), and fine-tune with offset.
A screen reader announces the notification count twice.
Cause. The count was put in the target's aria-label AND the count marker announces it via its own live region.
Fix. Label the target with its purpose only ('Notifications') for a count marker; reserve count-in-the-label for the bare dot case.
Accessibility
- Role
status
- The visual number is aria-hidden; the true (uncapped, ungrouped-source) count lives in an sr-only label, so '99+' or '1.2K' never hides the real value from assistive tech.
- The root is an aria-live=polite, aria-atomic region (the `live` prop, on by default) — count changes are announced without moving focus. Live regions stay silent on mount, so a static counter never speaks.
- Counter contributes only the number; label its owner for context (an 'Inbox' button + a Counter reads 'Inbox, 4'). Override the name with aria-label when the number alone isn't enough.
- The digit roll and the pulse halo both honor prefers-reduced-motion (instant swap / hidden halo).
- Counter.Indicator (the overlay) treats a marker as a visual cue tied to its target: the count marker carries Counter's own aria-live announce, while the bare dot is decorative (aria-hidden) and the target conveys meaning in its label (e.g. an avatar labelled 'Maya, online'). Label the target with its purpose only ('Notifications') for a count — the marker announces the number — and don't duplicate the count in both.