odometer roll.
A rolling number readout where only the columns that changed move, and every column rolls the way the value went.
live component, try it
what to expect.
how it works.
It comes down to three rules, and each one fixes a bug you only see once you look for it. The first is that direction belongs to the value, not to the digit. 1299 to 1301 runs the tens from 9 to 0, whose own shortest path is downward, which would put one column travelling backwards inside a number that went up, so every column takes the sign of the whole. The second is about identity rather than motion. Columns keyed by their position in the string are handed their neighbour's digit the moment a place is added or dropped, so 1234 losing its thousands would roll all three survivors to show digits that never moved. Keyed by place value, nothing moves that did not move. The third is a fixed advance per column, without which the readout shivers where it stands.
the component.
export const DIGITS = 10;
// Two copies of 0-9 so a roll across the wrap always has room to land.
export const ROWS = DIGITS * 2;
export const digitAt = (row: number): number => ((row % DIGITS) + DIGITS) % DIGITS;
export interface Roll {
rebase: number | null;
row: number;
}
export type Direction = "down" | "up";
export function rollTo(row: number, digit: number, dir: Direction = "down"): Roll {
const down = dir === "down";
const step = down
? (digitAt(row) - digit + DIGITS) % DIGITS
: (digit - digitAt(row) + DIGITS) % DIGITS;
const next = down ? row - step : row + step;
if (next >= 0 && next < ROWS) return { rebase: null, row: next };
// Jump ten rows invisibly first; the same digit is drawn there.
const rebase = down ? row + DIGITS : row - DIGITS;
return { rebase, row: down ? rebase - step : rebase + step };
}
// Every column rolls the way the whole value went.
export function directionFor(prev: number, next: number): Direction {
return next < prev ? "down" : "up";
}
export type Column =
| { kind: "digit"; place: number; digit: number }
| { kind: "sep"; place: number; char: string };
export function columnsOf(
value: number,
{ group = false, pad = 1 }: { group?: boolean; pad?: number } = {},
): Column[] {
const digits = String(Math.max(0, Math.floor(value))).padStart(pad, "0");
const columns: Column[] = [];
for (let i = 0; i < digits.length; i++) {
const place = digits.length - 1 - i;
if (group && i > 0 && (place + 1) % 3 === 0) {
columns.push({ kind: "sep", place, char: "," });
}
columns.push({ kind: "digit", place, digit: Number(digits[i]) });
}
return columns;
}
export function applyRoll(
el: HTMLElement,
row: number,
digit: number,
dir: Direction = "down",
): number {
const next = rollTo(row, digit, dir);
if (next.rebase !== null) {
el.style.transition = "none";
el.style.setProperty("--row", String(next.rebase));
// Force a reflow so the rebase is not merged into the roll.
void el.offsetHeight;
el.style.transition = "";
}
el.style.setProperty("--row", String(next.row));
return next.row;
}
export interface Rendered {
column: Column;
leaving: boolean;
}
// Keyed by place value, so adding or dropping a place does not shift digits between columns.
const keyOf = (column: Column): string => `${column.kind}${column.place}`;
export function departed(before: Column[], after: Column[]): Column[] {
const kept = new Set(after.map(keyOf));
return before.filter((column) => !kept.has(keyOf(column)));
}
function inReadingOrder(a: Column, b: Column): number {
if (a.place !== b.place) return b.place - a.place;
if (a.kind === b.kind) return 0;
return a.kind === "sep" ? -1 : 1;
}
export function withDeparted(current: Column[], departing: Column[]): Rendered[] {
return [
...current.map((column) => ({ column, leaving: false })),
...departing.map((column) => ({ column, leaving: true })),
].sort((a, b) => inReadingOrder(a.column, b.column));
}