Counter
A compact numeric count pill — notification totals and unread counts, with a 99+ overflow cap and an animated digit roll.
@garn/ui/counterDefault
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.
| solid | soft | |
|---|---|---|
| neutral | 8 | 8 |
| brand | 8 | 8 |
| info | 8 | 8 |
| success | 8 | 8 |
| warning | 8 | 8 |
| danger | 8 | 8 |
| discovery | 8 | 8 |
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- Source
- counter.tsx
- Deps
class-variance-authority- Registry deps
utils
Import
import { Counter } from "@garn/ui/counter";Props
Generated from the component's TypeScript 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"
live- Description
- Announce changes to assistive tech via an aria-live region. Default true.
- Type
boolean
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+`). Default 99.
- Type
number
pulse- Description
- Add a reduced-motion-safe "new activity" halo.
- Type
boolean
showZero- Description
- Render a `0` instead of hiding the counter. Default false.
- Type
boolean
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: solid (default) or soft.
- Type
"soft" | "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
format- Description
- Forwarded to the count marker.
- Type
"compact" | "standard"
invisible- Description
- Hide the marker while keeping the child in place.
- Type
boolean
live- Description
- Announce count changes via the count marker's live region. Default true.
- Type
boolean
locale- Description
- Forwarded to the count marker (SSR-deterministic formatting).
- Type
string
max- Description
- Forwarded to the count marker. Default 99.
- Type
number
offset- Description
- Nudge the marker inward by N px (fine-tuning against the target's radius).
- Type
number
overlap- Description
- Inset the marker inward for a round target (avatar). Default `rectangular`.
- Type
"circular" | "rectangular"
position- Description
- Corner to anchor the marker on. Default `top-end` (logical / RTL-safe).
- Type
IndicatorPosition
pulse- Description
- A reduced-motion-safe "new activity" halo on the marker.
- Type
boolean
shape- Description
- Count-marker shape: pill (default), rounded, or sharp.
- Type
"pill" | "rounded" | "sharp"
showZero- Description
- Render a `0` count instead of hiding the marker.
- Type
boolean
size- Description
- Marker size (shared with Counter).
- Type
"md" | "sm"
tone- Description
- Tone of the marker (shared with Counter).
- Type
CounterTone
withBorder- Description
- A ring in the surface color so the marker separates from the target. Default true.
- Type
boolean
Plus 278 inherited native HTML attributes.
Styling
Target these data-slots 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"]| Property | Token | Tier |
|---|---|---|
fill | --garn-badge-danger-boldbrandable | semantic |
Private instance vars (never externalize): --counter-ping
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.
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.
- Status
- Stable
- Version
v0.3.0- Introduced
v0.1.0
garn doesn't yet record a per-release version history. This reflects the component's current lifecycle state; full changelog entries will appear once the metadata pipeline emits a lifecycle.history field.