The living balance

A balance is a number that changes while you watch it, and it is money, so it carries a constraint a decorative number never does. It is not allowed to show a value that never existed.

At Bitstack I spend a lot of time on one number. It is the balance: the amount you have saved, shown at the top of the app, and it is never still for long. The price moves, and it moves with it. You buy, and it jumps. It is the first thing you look at and the last, and it is the number people screenshot.

That makes it the most demanding number in the product, and not because it is hard to compute. It is hard to be honest about. A figure that stands for money cannot show a value that never existed, and almost every motion and formatting question around a balance turns out to be that one rule wearing a different hat. Everything below is that constraint, six times over.

A number that moves without lying

The instinct, when a balance changes, is to count. Roll the digits, tick from the old figure up to the new one, let the eye follow the climb. It feels alive, and on a game score it is exactly right, because a score is arbitrary and the only thing that matters is that it went up.

A balance is not a score. Counting from 41,980 to 42,000 draws every figure in between, and on money every one of those figures is a claim: for a few frames the app states that you have 41,991 sats, then 41,994, then 41,997. You never had any of them. The number is interpolated, and interpolation on money is a small lie the eye believes because it looks like life.

Bitcoin
+7.35%
€48,256.22

The fix is to animate the transition and not the value. A digit that changed can roll into place while the digits that did not stay put, so the motion is real but the figure at every frame is either the old amount or the new amount, never a manufactured one in between. What moves is the type, not the truth.

// The lie. Sixty frames, sixty amounts, none of them real.
const shown = useSpring(balance)
return <span>{format(shown)}</span>
 
// The fix. What animates is y. Every cell is a real digit.
<span className="overflow-hidden" style={{ height: ROW }}>
  <motion.span
    animate={{ y: -ROW }}
    // Back to rest, or the next change starts from
    // the wrong cell.
    onAnimationComplete={() => setCells([next])}
    transition={{ duration, ease: "easeOut" }}
  >
    <span>{previous}</span>
    <span>{next}</span>
  </motion.span>
</span>

The market moved, or you did

Two things move a balance, and they mean opposite things. The price ticked, which happened to you, or you deposited, which you did. Both send the number up. If they animate the same way the interface has flattened a distinction the user cares about more than almost any other: was that me, or the market.

A market tick is ambient. It is continuous, small, not caused by you, and it should read that way: quiet, near constant, nothing to celebrate or mourn. A deposit is punctuated. You chose it, it is worth confirming, and it can afford a beat the tick cannot. The motion says which one it was before any label has to.

This is where a motion system earns its keep. A bounce belongs to something that arrives, like the deposit landing, and never to something that merely responds, like the price following the market. Give the tick a bounce and every passing cent reads as an event; give the deposit none and the one moment worth marking passes unmarked.

€48,256.22
Market tick
€48,256.22
Deposit
Same amount both sides. Only the motion says who moved it.

Sats, bitcoin, or your currency

The same amount has three honest names. Nought point nought nought nought four two bitcoin. Forty two thousand sats. Twenty six euros. Identical money, three completely different senses of size: 42,000 feels like a lot, 0.00042 feels like nothing at all, and the euro figure feels like a week of groceries. The unit is not a display preference. It is a framing decision, and the default you choose shapes how saving feels before the user has done anything.

It also decides a layout problem. Bitcoin carries eight decimal places, down to the satoshi, the smallest unit the protocol records; sats carry none, and a currency two, so switching unit changes the width of the figure enormously. That width change is a one-off, absorbed the moment someone picks a unit and settles. The update is not: without tabular figures the row jumps sideways every time the number changes, and a balance that twitches reads as unstable no matter how sound the amount is. The digits have to hold their place while they change.

42,000 sats
satsbitcoineuro
One amount, three names. The default decides how saving feels.

Green and red are not decoration

A day change is a pill, and it drifts while you read it: a gain in green, a loss in red, both moving a hundredth of a point at a time. The colour is carrying the information, which puts two demands on it at once. It has to clear the contrast a figure you act on needs, and it must not be the only thing saying gain or loss, because roughly one man in twelve will not read the hue.

Measured, from this site's own palette: green at #08863a and red at #e80425 both land at exactly 4.69 to 1 on white. That match is not luck. They are tuned to the same luminance, so a gain and a loss carry the same visual weight. A brighter green would make good news louder than bad, which is a thumb on the scale you did not mean to press, on a screen where people make decisions about their money.

The second channel is a sign, an arrow, or a word, so the meaning survives with the colour removed. And the section raises a harder question than it can tidily answer: a red day is information the user needs and also the thing most likely to make them sell at the wrong moment. Whether you let someone soften it is a real decision, and pretending it is only a styling one is how it gets made by accident.

-1.10%
+2.40%
Remove the hue
Take the hue away and only the sign tells them apart.

Rounding is a correctness problem

This is the least visual thing here and the most exacting. A displayed amount is not allowed to lie, and there are two quiet ways it does.

Floating point

Hold money in a floating point number and it drifts. 0.1 + 0.2 is not 0.3, it is 0.30000000000000004, and a balance summed that way accumulates error one operation at a time until the figure on screen disagrees with the ledger by a satoshi nobody can find. Money is counted in its smallest whole unit, sats for bitcoin and cents for a currency, kept as integers through every sum, and turned into a decimal only at the very last step, for display and nowhere else.

Rounding up

toFixed looks like the last step and is a trap. It rounds off the wrong number, because the float it is handed is not the decimal you typed: (2.55).toFixed(1) returns 2.5, rounding down, because the closest float to 2.55 sits just below it. Worse than the rounding is the direction. Rounding a balance to the nearest unit will sometimes round it up, and a balance rounded up shows money that is not there.

// Two lies, and toFixed tells both. It rounds the float it got:
;(2.55).toFixed(1) // "2.5": the closest float to 2.55 sits below
// and it rounds to NEAREST, so a balance sometimes rounds up.
 
// Integers the whole way, then one truncation toward zero.
const cents = Math.trunc((sats * CENTS_PER_BTC) / SATS_PER_BTC)

A holdings figure truncates toward zero, so it can understate by a fraction of a cent but never claims a fraction you do not hold.

0.00,041,999 BTC
exactly 41,999 sats
ledgerroundedtruncated
Rounding claims a satoshi nobody holds. Truncating claims nothing.

When the number should hold still

Under reduced motion the whole living balance goes quiet: the rolls, the ticks, the beat on a deposit, all of it drops to an instant change, because the person has told their system they do not want to be moved and the balance is not exempt.

There is a stronger version of the same instinct that owes nothing to the setting. On a price line, the number in the header should not animate at all, for anyone, ever. Interpolating it looks alive and prints prices that never happened, so the reading should snap to real samples while only the dot on the line glides. The motion stays continuous while every figure on screen is one that occurred.

// The line is smoothed toward its target, 8% per frame.
drawn.y += (target - drawn.y) * 0.08
 
// The header is not. It takes a sample, or it waits.
header.textContent = format(SERIES[Math.floor(head)])

That is the whole article in one line. Motion on money is not for delight, it is for truth. Every animation here earns its place by making a real change legible, and at the exact point where it would invent a value instead, the right amount of motion is none. The line below breaks that rule on purpose: its header is smoothed toward each new sample the way a live chart usually is, so the figure you read is almost never one that occurred.

Bitcoin
+0.30%
Smoothed on purpose: every reading is a price that never happened.

Start your own balance

The easiest way to save and invest in bitcoin, and the app this article's number comes from.

Try Bitstack