Oct 2, 2026 · Component
TabularTimer
The same clock twice, in the same font. The top row uses the font's default proportional figures and changes width on every tick; the bottom row asks for tabular figures and holds perfectly still. One CSS property separates them.
Watch the dashed edge: one number breathes, the other holds.
What it shows, and why
Digits in most text fonts are drawn like letters: a 1 is narrower than a 0. Fine in prose, wrong in anything that updates in place. A live timer, a price that refreshes, a percentage in a dashboard: each tick redraws a number of a different width, and everything after it shifts. The reader's eye tracks the jitter instead of the value.
font-variant-numeric: tabular-nums asks the same font for its fixed-width digits, and the number stops moving. This site's own font, Geist, defaults to proportional figures, which is what the top row demonstrates: measured here in the browser, “11.11s” runs 102.6px where “00.00s” runs 164.5px, and in tabular both hold 151.6px. The clock itself reads performance.now() each frame, so the frame rate decides how often it looks, never what it says.
The place this bites hardest is the one this site just spent an essay on: an AI's thinking indicator, counting its elapsed seconds while you stare at it. That figure updates every frame in front of a reader with nothing else to do, which is why it is the worst place for a trembling number, and the whole subject of Designing the wait.
Use it
One file, Tailwind only, no other dependency. Everything is in the single block below: the usage sits at the top as a comment, the component follows, and Copy takes the whole thing.
The demo only proves its point if your font's default figures are proportional. If the number does not tremble, your font already ships tabular (or monospaced) digits, and the lesson is free.
// Usage: copy this whole file, then
//
// import { TabularTimer } from "@/components/tabular-timer";
//
// <TabularTimer />
//
// No props: the figure is the argument.
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
const ACCENT = "#8a3a5c";
const HAIRLINE = "border-black/10 dark:border-white/10";
function format(ms: number) {
return `${(ms / 1000).toFixed(2)}s`;
}
function Row({
label,
numeric,
value,
good,
}: {
label: string;
numeric: "proportional-nums" | "tabular-nums";
value: string;
good?: boolean;
}) {
return (
<div
className={`grid grid-cols-[1fr_28px] items-center gap-4 py-[22px] min-[521px]:grid-cols-[112px_1fr_28px] ${HAIRLINE}`}
>
<span className="text-[15px] opacity-70 max-[520px]:col-span-full">
{label}
</span>
<span className="flex min-w-0 items-baseline gap-3.5 overflow-hidden whitespace-nowrap">
<span
className="rounded border border-dashed px-1 py-1 text-[clamp(32px,6vw,48px)] font-[450] leading-none tracking-[-0.02em]"
style={{ fontVariantNumeric: numeric, borderColor: ACCENT }}
>
{value}
</span>
<span className="text-sm opacity-50 max-[520px]:hidden">
Querying warehouse
</span>
</span>
<span
aria-label={good ? "Recommended" : "Not recommended"}
className={`grid size-[26px] place-items-center rounded-full ${
good ? "" : "bg-black/10 dark:bg-white/10"
}`}
style={good ? { backgroundColor: ACCENT } : undefined}
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
className={`size-3.5 fill-none ${good ? "stroke-white" : "stroke-current opacity-70"}`}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
{good ? (
<path d="M4.5 8.5l2.2 2.2 4.8-5" />
) : (
<path d="M5 5l6 6M11 5l-6 6" />
)}
</svg>
</span>
</div>
);
}
export function TabularTimer() {
const [elapsed, setElapsed] = useState(0);
const [running, setRunning] = useState(true);
const startRef = useRef<number | null>(null);
const offsetRef = useRef(0);
const frameRef = useRef<number | null>(null);
const tick = useCallback(() => {
if (startRef.current !== null) {
setElapsed(offsetRef.current + performance.now() - startRef.current);
}
frameRef.current = requestAnimationFrame(tick);
}, []);
useEffect(() => {
if (running) {
startRef.current = performance.now();
frameRef.current = requestAnimationFrame(tick);
} else if (startRef.current !== null) {
offsetRef.current += performance.now() - startRef.current;
startRef.current = null;
}
return () => {
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
};
}, [running, tick]);
const restart = () => {
offsetRef.current = 0;
startRef.current = performance.now();
setElapsed(0);
setRunning(true);
};
const value = format(elapsed);
const btn =
"cursor-pointer rounded-full px-3.5 py-[7px] text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#8a3a5c]";
return (
<figure className="m-0 w-full">
<div
className={`overflow-hidden rounded-3xl border bg-white shadow-[0_1px_3px_rgba(0,0,0,0.06),0_8px_24px_rgba(0,0,0,0.05)] dark:bg-white/[0.035] dark:shadow-none ${HAIRLINE}`}
>
<div className="px-[clamp(20px,6vw,72px)] pb-8 pt-10 [&>div:first-child]:border-t-0 [&>div]:border-t">
<Row label="Proportional" numeric="proportional-nums" value={value} />
<Row label="Tabular" numeric="tabular-nums" value={value} good />
</div>
<div
className={`flex flex-wrap items-center justify-end gap-3 border-t px-[clamp(16px,4vw,32px)] py-4 ${HAIRLINE}`}
>
<div className="inline-flex gap-2">
<button
type="button"
className={`${btn} bg-transparent opacity-70`}
onClick={() => setRunning((r) => !r)}
>
{running ? "Pause" : "Resume"}
</button>
<button
type="button"
className={`${btn} bg-black text-white dark:bg-white dark:text-black`}
onClick={restart}
>
Restart
</button>
</div>
</div>
</div>
<figcaption className="mt-3.5 text-center text-[13px] italic opacity-55">
Same clock, same font. Only one of them keeps its width.
</figcaption>
</figure>
);
}