How to stop numbers jittering when they change.
Set a stopwatch in Inter at 48 pixels and let it tick from 0:40 to 0:41. The label gets 13.9 pixels narrower in one second. Centred, both of its edges twitch inward by seven pixels; left-aligned, everything after it slides over. Your layout is fine. The font gives every digit a different width, and nobody asked it not to.
Most fonts give every digit its own width
Type designers draw digits two ways. Proportional figures are spaced like letters: the 1 is narrow, the 0 is round and wide, and a year in the middle of a sentence sits evenly instead of leaving a hole where the 1 is. Tabular figures all share one width, like a typewriter’s, so columns line up and a number can change in place without moving anything around it. Good fonts carry both sets and pick one as the default.
Which one is the default is the whole problem. These are the advance widths of the digits 0 through 9, read straight out of the font files (each font has its own unit scale, so compare down a column, not across a row):
digit Roboto SF Inter
0 1151 1242 1292
1 1151 908 833
2 1151 1159 1249
3 1151 1212 1265
4 1151 1238 1323
5 1151 1198 1215
6 1151 1264 1270
7 1151 1120 1159
8 1151 1228 1267
9 1151 1264 1270
Roboto, Android’s system font, is tabular out of the box. San Francisco, Apple’s, is not: when Apple introduced it in 2015 it switched the system default from monospaced digits to proportional ones, and added a separate monospaced-digit variant of the system font for the places that need it. Inter is proportional too, and so is almost everything else. Of the 15 font families bundled in the ShotCanvas app, 14 default to proportional digits; the exception is JetBrains Mono, which is monospaced throughout.
That split is how cross-platform teams miss it. On default typography, Flutter’s Material theme sets text in Roboto on Android and in San Francisco on iOS (the per-platform text themes are right there in the SDK source). A countdown that sits perfectly still on the Android phone you test with shakes on every iPhone.
How far a ticking number actually moves
To put a size on it, I rendered every value from 0:00 to 9:59 at 48px in Chromium and measured
each one. “Range” is widest minus narrowest; “worst tick” is the biggest
change between two consecutive seconds. San Francisco here is the Mac’s
system-ui:
48px range worst tick
Inter 36.8px 13.9px
SF 24.5px 8.3px
DM Sans 59.2px 22.9px
Roboto 0px 0px
Inter tabular 0px 0px
Inter’s worst tick is 0:40 to 0:41, San Francisco’s is 0:09 to 0:10, and DM Sans gets 22.9 pixels wider going from 1:59 to 2:00. Fourteen pixels is not a subtle wobble on a phone. Where the movement lands depends on alignment: a centred number breathes in and out from both edges, a left-aligned one drags whatever follows it (a unit, a button, the rest of the sentence), and a right-aligned one swings its left edge. Totals, follower counts, upload progress, scores and prices all do this. A timer is just the case where it happens once a second, so it’s the one people notice.
The fix is one line on every platform
Ask for tabular figures on the text that changes:
/* CSS */
.timer { font-variant-numeric: tabular-nums; }
// Flutter
TextStyle(fontFeatures: [FontFeature.tabularFigures()])
// SwiftUI
Text(elapsed).monospacedDigit()
// UIKit, system font
UIFont.monospacedDigitSystemFont(ofSize: 48, weight: .medium)
// Jetpack Compose
TextStyle(fontFeatureSettings = "tnum")
// Android views (XML attribute)
android:fontFeatureSettings="tnum"
Underneath, they all switch on the font’s OpenType tnum feature, which
swaps each digit for a tabular twin. In the table above that took Inter from 36.8 pixels of
travel to none. The price is a slightly wider label, because every digit now takes roughly the
width of the widest one: Inter’s 0:00 went from 98.9px to 105.7px.
It does nothing when the font has no tabular digits
That feature is a request, not a transformation. If the font has no tabular set, the setting
is ignored, with no warning, no fallback and nothing in the logs. DM Sans measured identically
with and without tabular-nums: 59.2 pixels of travel either way. Poppins behaved
the same. In the builds we ship, five of our fifteen families have no tabular digits at all:
DM Sans, Poppins, Fraunces, Playfair Display and Instrument Serif.
1111 on one line and
0000 directly below it, left-aligned. If the right edges don’t meet, the font
has nothing to switch to. It works in any toolkit, and you don’t need font tools
installed to run it.
When your brand face fails that test, you have two honest options. Set the changing number in a face that does have tabular digits. The system font is a safe partner for most sans-serifs, and a number set slightly differently from its label reads as data rather than as a mistake. Or, for one large countdown, give each digit its own fixed-width box, sized to the widest digit, with the digit centred inside it. That stops the motion but loosens the spacing, so keep it for display numbers, not text.
What tabular figures won’t fix
Equal-width digits only stop the movement while the number of characters stays the same. Three ordinary changes break that.
More digits. 9:59 becomes 10:00, 99% becomes 100%, and 999 becomes 1,000 and picks up a comma. Reserve room for the longest value you can show by laying an invisible copy of it under the live one:
Stack(
alignment: Alignment.centerRight,
children: [
Opacity(
opacity: 0,
child: Text('00:00', style: style),
),
Text(elapsed, style: style),
],
)
In CSS, put both spans in the same grid cell and give the sizer
visibility: hidden. Either way the box is as wide as the worst case from the
first frame, and nothing next to it moves.
Signs. A balance that goes from 3 to −3 grows by a character. Keep a slot for the sign, and use the real minus sign (U+2212) rather than a hyphen. In both Inter and San Francisco the minus is exactly as wide as the plus, so +3 and −3 take the same space; the hyphen is about 30% narrower than either.
Padding. To right-align a single digit against double ones without layout code, pad with U+2007 FIGURE SPACE, which Unicode defines as the width of one tabular digit. In Inter, San Francisco, Roboto and Montserrat it measured exactly that. DM Sans, Poppins and Outfit don’t include the character at all, so it falls back to another font at whatever width that font chose. Run the test again.
Don’t switch it on everywhere
The tempting fix is to put tabular figures on the body style and move on. Don’t. Proportional digits are the default because they read better in running text. Inter’s tabular 1 sits in a slot about 60% wider than its natural width (1328 units against 833), so “1,100 reviews” or “since 2011” picks up visible gaps. Use tabular figures where numbers change in place or stack in columns, such as timers, counters, live totals, prices in a list and table cells, and leave prose and headlines alone.
We split it the same way in ShotCanvas Studio: the export progress count and the slider readouts use tabular figures, and the headline type in your frames keeps each font’s proportional digits, because a still image never ticks. The UI inside the frame is the exception. A column of prices or stats that doesn’t line up looks careless even frozen, and that fix belongs in your app’s code before you take the capture.