Coin counter
NewA game wallet readout: gems fly into its icon and the amount rolls up digit by digit, then it slips away.
coin-counter/coin-counter.tsxThe live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- A game’s wallet: one currency the player gains and spends often, read in a corner of the screen without stopping play.
- Gains worth celebrating a little: a pickup flies into the icon and the amount rolls, so the player sees what they got as it lands.
- A HUD that should stay out of the way: the readout slides in on each change and slips away a few seconds later.
- Refusing a purchase the player cannot afford, with a shake and a red flash rather than a dialog.
- Pickups in kinds of different worth (here six gem colours), each with its own glow when it lands.
When not to use
- A balance in an app or a bank: money read carefully and rarely changing in bursts. Use a plain figure with its currency, or a stat card.
- Several currencies or resources at once. Use a resource bar that keeps every one on screen.
- A score that only ever climbs and is always visible. Use a static score label; hiding it would only get in the way.
- A count the player must check at any moment, such as ammunition in a fight. Use an always-on counter, or keep this one up with
hideAfter={0}. - A shop itself, with items, prices and confirmation. Use a shop menu; this only shows the wallet beside it.
You can add this component with the shadcn CLI or by hand:
Into components/coin-counter of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/coin-counter.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { CoinCounter } from './coin-counter';export function Wallet({ gems, setGems }: { gems: number; setGems: (n: number) => void }) { return ( <CoinCounter value={gems} onChange={(next) => setGems(next)} price={250} hideAfter={4000} autoplay={false} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
The HUD readout that tells a player what they hold. Pick up one of six coloured gems and it pops in, flies into the icon with a flash of its colour, and the amount rolls like an odometer, each digit turning as the one below it wraps. Spending rolls it down; spending too much shakes it red. A few idle seconds later the readout fades away. It sits on its own dark plate, as game HUDs keep under their figures, so it reads on a light page as on a dark one.
- Each digit is a strip of eleven figures (0 to 9, then 0 again) behind a one-line window. One number, the amount on its way from the old value to the new, places every strip: the units follow it continuously, and each higher digit turns only during the last unit before the digits below it wrap, so a carry ripples up one digit at a time.
- The roll lasts 240 ms for a single gem and grows with the log of the change, to under a second for hundreds, eased out. It writes only transforms, once per frame, outside React; React draws the strips at the settled amount.
- A digit with nothing above it folds to zero width, so the amount keeps its shape as it gains or loses a digit, and the thousands comma folds with it.
- A pickup is one beat: the gem pops in under the readout (small, overshooting, settling), then flies up into the icon on a CSS keyframe path in 470 ms. As it lands the icon swells and a glow in that gem’s colour flares behind it (Web Animations API), and the roll starts.
- The change shows as “+20” under the amount; changes of the same sign within 1.2 s add up into it, as a combo. Spending shows “−500” in warm red; spending more than the wallet holds shakes the readout and flashes the amount red instead.
- The readout hides itself a few seconds after the last change (hideAfter), and stays while the pointer or the focus is on the gem row, as a wallet stays open in a shop.
- The gems are one 25 × 45 drawing of seven flat facets lit from the top left; each colour is a set of facet tokens in the CSS. Everything is SVG and text, so it stays sharp at any size and pixel density.
- The icon and amount sit on a dark pill plate, and the change label on a smaller one under it: the cream figures need a dark ground, so the plate is the same on both colour schemes and only its edge lifts at night. The readout is drawn at 1.4 times the HUD’s own sizes (1.15 on a narrow stage), from one --k factor in the CSS.
- While on screen and untouched, a demo picks up a run of gems, buys something and lets the readout hide, then starts over from the same amount. Any real input hands it over.
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | — | The amount, controlled: the counter rolls to each new value. Leave it out to let the counter keep its own. |
defaultValue | number | 13878 | The starting amount when uncontrolled, and where the demo starts each run. |
onChange | (value: number, change: number, color: GemColor | null) => void | — | Called with the new amount, the change and the gem picked up (null for a purchase). |
gems | { color: 'green' | 'blue' | 'red' | 'purple' | 'silver' | 'gold'; value: number; label?: string }[] | GEMS | The gems the row offers, in order, and what each is worth (1, 5, 20, 50, 100 and 300 by default). Keys 1 to 9 pick them up. |
price | number | 500 | What the Spend button costs. 0 hides it. |
hideAfter | number | 3200 | The readout hides this many ms after the last change; 0 keeps it on screen. |
showControls | boolean | true | Shows the row of gems and the Spend button. |
unit | string | 'gems' | What the amount counts, for screen readers. |
autoplay | boolean | true | While on screen and untouched, a demo picks up gems, buys something and lets the readout hide. |
colorScheme | 'light' | 'dark' | 'system' | — | Light or dark stage and chips, or the reader’s; the readout keeps its dark plate on both. Without it, the page’s color-scheme. |
defaultState | 'shown' | 'hidden' | 'gain' | 'spend' | 'short' | — | Opens held in one moment until the reader picks up or spends: the readout at rest, hidden, just after a red gem (“+20”), just after a purchase (“−500”), or refusing one in red. Turns the demo off. |
className | string | — | Added to the root. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent,} from 'react';import { Gem, type GemColor } from './gem';import { useAutoplay } from './use-autoplay';import styles from './coin-counter.module.css';/* * Coin counter: a game HUD's wallet. A pair of gem icons and an amount on a dark plate, shown on * its own; it comes in when the amount changes, plays the change out, and slips away a few seconds * later. Gems come in six colours, each worth a set amount, and a row of them below picks one up. * * - The amount rolls like an odometer. Each digit is a strip of eleven figures (0 to 9, then 0 * again) in a one-line window; one number, the amount on its way from the old value to the new, * places every strip. The units follow it continuously; each higher digit holds still and turns/* Coin counter (coin-counter.tsx): the wallet readout alone on the lab's plain stage, with a row of gems to pick up and a Spend chip under it. The readout is drawn as a game HUD draws it, but on its own dark plate (a pill), so the cream figures read on a white stage as well as a near-black one; the change under it ("+20") sits on a smaller plate of its own. Its sizes are the HUD's (25 × 45 gems 5 px apart, 32 px figures) times --k: 1.4 on a roomy stage, 1.15 on a narrow one (a container query). Colours are the tokens below, light-dark(light, dark) where the two differ: the stage and the chips follow the color-scheme the component inherits, or the one its colorScheme prop sets (data-color-scheme). The plate, the cream figures and the gems are game art: the same on both, the plate's edge a step lighter at night so it parts from the stage. Crisp throughout: SVG and text, no raster, no blur on any shape. The only soft things are the glow that flares behind the icon and the figures' shadow, layers behind sharp shapes. */.root { /* The stage, and its edge. */ --surface: #fff; --line: rgb(0 0 0 / 0.06); /* The readout's plate, its edge and its lift. */ --plate: rgb(20 24 22 / 0.9); --plate-edge: rgb(255 255 255 / 0.06); --plate-shadow: 0 6px 18px rgb(0 0 0 / 0.16), 0 1px 3px rgb(0 0 0 / 0.18);// Copyright (c) 2026 Bruno Delorence. MIT License.import styles from './coin-counter.module.css';/* * A gem: a tall six-sided crystal cut into seven flat facets, lit from the top left. The drawing is * 25 × 45 units; its colours come from the stylesheet (one set of facet tokens per colour, picked by * data-color), so one drawing serves all six gems. Each facet is stroked in its own colour, a * hair wide, so no seam of what is behind shows between two facets. */export type GemColor = 'green' | 'blue' | 'red' | 'purple' | 'silver' | 'gold';/** The facets, from the brightest to the darkest side: each a polygon in the 25 × 45 box. */const FACETS: [string, string][] = [ // The flat top, lit. ['tl', '12.5,0 0,11 6,14.5 12.5,9.5'], ['tr', '12.5,0 12.5,9.5 19,14.5 25,11'], // The sides. ['left', '0,11 6,14.5 6,30.5 0,34'], ['right', '25,11 19,14.5 19,30.5 25,34'], // The table, the big face in the middle. ['table', '12.5,9.5 19,14.5 19,30.5 12.5,35.5 6,30.5 6,14.5'], // The bottom.// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. `reduced` also says whether the reader asks for * reduced motion, so the component can skip its own animations too. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden);- The readout carries its amount as text for screen readers (“13,878 gems”); the rolling figures, the icon and the flying gems are hidden from them.
- A polite live region says each change once it settles (“Plus 20. 13,898 gems.”), and says when a purchase is refused.
- The gems are real buttons named with their worth (“Red gem, plus 20”), in a labelled group; keys 1 to 9 pick them up and minus spends, announced with aria-keyshortcuts.
- Every button shows a focus ring, and the readout stays on screen while the focus is on the row.
- Under reduced motion the amount changes at once, with no roll, flight, swell or shake; the change label only fades, and the demo stays off.