Comparison Counter

A small labelled counter built for algorithm visualisers. Each time the parent updates value, the number re-mounts and runs a bouncy scale animation, so a live operation count reads as motion rather than as a silent number swap.

Preview
Comparisons:0
Customize
Options
700
1.15

Installation

npx shadcn@latest add https://craftbits.dev/r/comparison-counter.json

Usage

import { ComparisonCounter } from "@craft-bits/edu";
 
<ComparisonCounter value={count} label="Comparisons:" />

Tint the value by setting color on a parent. The value uses currentColor, so any wrapper that sets text-cb-accent (or any color token) flows through:

<div className="text-cb-accent">
  <ComparisonCounter value={count} />
</div>

Fire a callback once the counter first starts incrementing — useful for kicking off a follow-up animation when the algorithm finishes its first step:

<ComparisonCounter
  value={count}
  onComplete={(v) => console.log("first tick", v)}
/>

Anatomy

  • Rootinline-flex row, spreads unknown props onto a <div> with role="status" and aria-live="polite".
  • Label — muted monospace text on the left. Pass an empty string to suppress it.
  • Value — tabular-nums, coloured by currentColor, wrapped in AnimatePresence with mode="popLayout" and keyed by the value itself.
  • Pop on change — every value update animates scale from maxScale to 1 on SPRINGS.bouncy. The maxScale prop is clamped to [1, 1.2] by the craft-bits/subtle-deformation-scale lint rule.
  • Reduced motion — when the user prefers reduced motion, the entrance scale is skipped — the new digit appears immediately at scale 1.
  • onComplete — fires at most once per instance, on the first value update after mount.

Props

PropTypeDefaultDescription
valuenumberrequiredNumeric value to display. Drives the pop-on-change animation.
labelReactNode'Comparisons:'Label to the left of the value. Empty string suppresses it.
maxScalenumber1.15Peak scale during the value-change animation. Clamped to [1, 1.2].
onComplete(value) => voidFires once when value first changes after mount.
aria-labelstringderivedAccessible label for the status region.
classNamestringMerged onto the root via cn().

Accessibility

  • The root is role="status" with aria-live="polite" and aria-atomic="true", so screen readers announce the new value on every change.
  • When label is a string, the aria-label falls back to "<label> <value>" for screen-reader context. Override it via the aria-label prop for non-string labels.
  • Reduced-motion users get an instant value swap — no scale animation.

Credits

  • Extracted from: AlgoFlashcards (src/lessons/primitives/chrome/ComparisonCounter.tsx). The original used an inline style color and a project-specific spring import; this primitive consumes currentColor, the shared SPRINGS.bouncy token, and adds an onComplete hook for terminal-state wiring.