Ledger — double entry
Variant: Double entry
A transfer between two accounts where the total is the thing that must not move. One integer is the amount in flight and both balances are derived from it, so the books cannot disagree on any frame — two counters tweened separately round independently and drift a dollar apart mid-flight, which is exactly the flicker a finance UI cannot afford. A $290 chip lifts 4px out of Cash in 150ms, crosses on 512ms of ease-in-out because it travels between two known points on screen, and merges into Equipment while both balances count on the same curve; Cr and Dr tags name the two sides of the posting while it is news, and the second posting runs the same path upward, so the loop closes on its opening figures. The total line — its olive mark and Dr = Cr — sits perfectly still through both: the invariant shown as the absence of motion. Under reduced motion the chip is dropped and the tick and tags carry the transfer alone. Use it for transfers, inventory moves, and any operation that must visibly conserve.
The source
A complete HTML document. Paste it into an empty .html file and it plays — no stylesheet, script, font, or image to fetch.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Ledger — double entry</title>
<style>
/* One integer is the transfer. Both balances are derived from it, so on
no frame — not even mid-tween, where two independently rounded
counters would drift a dollar apart — can the books disagree. */
@property --_x {
syntax: '<integer>';
inherits: true;
initial-value: 0;
}
:root {
/* Neutrals — fixed, never remixed. */
--ivory: #faf9f5;
--paper: #ffffff;
--oat: #e3dacc;
--slate: #141413;
/* Knobs — the remix API. A knob exists iff declared here and used below. */
--hue: 38.8;
--lift: 0;
--chroma: 1;
--speed: 1;
/* Accents — derived from the knobs with the family's offsets baked in,
so one hue roll moves them together. Defaults reproduce the original hex. */
--clay: oklch(calc(0.6724 + var(--lift)) calc(0.1308 * var(--chroma)) var(--hue));
--clay-d: oklch(calc(0.5797 + var(--lift)) calc(0.127 * var(--chroma)) var(--hue));
--olive: oklch(
calc(0.6118 + var(--lift)) calc(0.0713 * var(--chroma)) calc(var(--hue) + 88.3)
);
--rust: oklch(
calc(0.5408 + var(--lift)) calc(0.1357 * var(--chroma)) calc(var(--hue) - 10.3)
);
/* Clock — 6.4s: two postings, one each way, so the loop closes on the
balances it opened with. Each is a 150ms lift, a 512ms crossing and
a 150ms merge, then 2.4s of standing still. */
--cycle: 6.4s;
--t: calc(var(--cycle) / var(--speed));
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
--_muted: color-mix(in srgb, var(--slate) 52%, transparent);
--_row: clamp(38px, 11vw, 44px);
--_amt: 52px;
--_rule: color-mix(in srgb, var(--oat) 70%, transparent);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
display: grid;
place-items: center;
background: var(--ivory);
font-family:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
color: var(--slate);
}
.book {
width: min(300px, 86vw);
animation: transfer var(--t) infinite;
}
.head {
display: grid;
font-size: clamp(11px, 3.2vw, 12px);
margin-bottom: 4px;
}
.label {
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--_muted);
margin-bottom: 4px;
}
/* The memo for each posting shares one cell with the other, so the
header never changes height as it swaps. */
.memo {
display: grid;
color: var(--_muted);
}
.memo span {
grid-area: 1 / 1;
animation: var(--_k) var(--t) infinite;
}
.rows {
position: relative;
}
.row {
height: var(--_row);
display: flex;
align-items: center;
justify-content: space-between;
font-size: clamp(13px, 3.8vw, 14px);
}
.row + .row {
border-top: 1px solid var(--_rule);
}
.amt {
font-weight: 600;
font-variant-numeric: tabular-nums;
min-width: var(--_amt);
text-align: right;
}
/* The bookkeeping, named: every posting is a credit on one row and an
equal debit on the other, tagged for as long as the posting is news. */
.name {
display: flex;
align-items: baseline;
gap: 8px;
}
.side {
display: grid;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
color: var(--_muted);
}
.side span {
grid-area: 1 / 1;
opacity: 0;
animation: var(--_k) var(--t) infinite;
}
.cash .amt {
counter-reset: v calc(840 - var(--_x));
}
.kit .amt {
counter-reset: v calc(360 + var(--_x));
}
.amt::after {
content: '$' counter(v);
}
/* The amount in transit: a chip that lifts out of one balance, crosses
the rule, and merges into the other. It is the only thing that
travels — the balances tick in place on the same curve, so the eye
follows the money and the readouts confirm it on arrival. */
.chip {
position: absolute;
top: calc(var(--_row) / 2 - 11px);
right: calc(var(--_amt) + 14px);
height: 22px;
padding: 0 8px;
display: grid;
place-items: center;
border-radius: 999px;
background: var(--clay-d);
color: var(--paper);
font-size: 12px;
font-weight: 600;
font-variant-numeric: tabular-nums;
opacity: 0;
animation: chip var(--t) infinite;
}
.foot {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 4px;
padding-top: 10px;
border-top: 1px solid var(--slate);
font-size: clamp(13px, 3.8vw, 14px);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
/* The check never moves. Everything else in the ledger is animated so
that this line can stand perfectly still through both postings — the
invariant, shown as the absence of motion. */
.foot span:first-child {
display: flex;
align-items: center;
gap: 7px;
}
.foot small {
font-size: 11px;
font-weight: 600;
color: var(--_muted);
letter-spacing: 0.06em;
}
.foot i {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--olive);
}
@keyframes transfer {
0%,
9% {
--_x: 0;
animation-timing-function: var(--ease-in-out);
}
17%,
59% {
--_x: 290;
animation-timing-function: var(--ease-in-out);
}
67%,
100% {
--_x: 0;
}
}
/* Lift 4px out of the balance eased out, cross on ease-in-out because it is on screen
travelling between two known points, merge with a quick fade. The
second posting runs the same path upward. */
@keyframes chip {
0%,
6.6% {
opacity: 0;
transform: translateY(4px) scale(0.94);
animation-timing-function: var(--ease-out);
}
9% {
opacity: 1;
transform: translateY(0) scale(1);
animation-timing-function: var(--ease-in-out);
}
17% {
opacity: 1;
transform: translateY(var(--_row)) scale(1);
animation-timing-function: var(--ease-out);
}
19.3%,
56.6% {
opacity: 0;
transform: translateY(calc(var(--_row) + 4px)) scale(0.94);
animation-timing-function: var(--ease-out);
}
59% {
opacity: 1;
transform: translateY(var(--_row)) scale(1);
animation-timing-function: var(--ease-in-out);
}
67% {
opacity: 1;
transform: translateY(0) scale(1);
animation-timing-function: var(--ease-out);
}
69.3%,
100% {
opacity: 0;
transform: translateY(0) scale(0.94);
}
}
/* Incoming memo eases in over 180ms while the outgoing one leaves in
120ms from the same frame, so the header is never blank and the two
lines are never both at full ink. */
@keyframes memo-1 {
0%,
4% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
6.8%,
54% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
55.9%,
100% {
opacity: 0;
}
}
@keyframes memo-2 {
0%,
4% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
5.9%,
54% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
56.8%,
100% {
opacity: 1;
}
}
/* Dr/Cr tags: in with the chip, out once the balances have settled. */
@keyframes tag-a {
0%,
6.6% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
9%,
40% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
44%,
100% {
opacity: 0;
}
}
@keyframes tag-b {
0%,
56.6% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
59%,
90% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
94%,
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
/* The chip is dropped and the balances count in place: the tick
carries the transfer on its own, over an 11.5s loop. The memo and
the Dr/Cr tags still fade, since they are what say which way the
money went. */
.chip {
animation: none;
}
.book,
.memo span,
.side span {
animation-duration: calc(var(--t) * 1.8);
}
}
</style>
</head>
<body>
<div
class="book"
role="img"
aria-label="A small ledger with Cash at 840 dollars and Equipment at 360. A 290 dollar laptop purchase is credited to Cash and debited to Equipment, then selling old kit posts 290 back the other way; both balances count together while the 1,200 dollar total and its balanced mark never change"
>
<div class="head">
<span class="label">Assets</span>
<span class="memo">
<span style="--_k: memo-1">Laptop · Cash → Equipment</span>
<span style="--_k: memo-2">Sold old kit · Equipment → Cash</span>
</span>
</div>
<div class="rows">
<div class="row cash">
<span class="name"
>Cash
<span class="side"
><span style="--_k: tag-a">Cr</span><span style="--_k: tag-b">Dr</span></span
></span
><span class="amt"></span>
</div>
<div class="row kit">
<span class="name"
>Equipment
<span class="side"
><span style="--_k: tag-a">Dr</span><span style="--_k: tag-b">Cr</span></span
></span
><span class="amt"></span>
</div>
<span class="chip">$290</span>
</div>
<div class="foot">
<span><i></i>Total <small>Dr = Cr</small></span>
<span>$1,200</span>
</div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.