Skip to content

Counter

A compact numeric count pill — notification totals and unread counts, with a 99+ overflow cap and an animated digit roll.

Stablev0.4.2added in v0.1.0@garn/ui/counter
On 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.

terminal
npx garn-ui add counter
First time? Set up garn in your project
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

SelectorState
rolling
[data-pulse]pulse
hidden-at-zero
PropertyTokenTier
fill--garn-badge-danger-boldbrandablesemantic

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

Do
  • 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
  • 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
A1.4.1Use of ColorAA1.4.3Contrast (Minimum)AAA2.3.3Animation from InteractionsAA4.1.3Status Messages
  • 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.