I build Repceipt, a receipt editor that runs in the browser. Its template library is made by redrawing real receipts, one element at a time, and each redraw records which typefaces it uses and at what sizes. By August we had 632 of them, which meant we were sitting on a dataset I'd never seen anyone collect: what fonts receipts actually use.
I went in assuming the answer was "one". Some bitmap face burned into every thermal printer on earth, end of story.
It's 24.
This post is the numbers, and then the parts that matter if you ever have to draw a receipt yourself: why they're monospaced, how to fake that in CSS when they aren't, what to do about the barcode, and the four silent bugs between "looks right in the browser" and "looks right in the exported PNG".
First, what this data is and isn't
These are redrawn receipts. Each one rebuilds a real document as closely as possible, so the fonts are the typefaces chosen to reproduce receipts. Where the original came off a printer's built-in bitmap font, the redraw uses the nearest web font. So this tells you what receipt typography looks like and how much it varies. It is not a survey of printer firmware.
We also only measured structure: typeface names, sizes, element types, canvas dimensions. No text contents. Real receipts carry real card digits and addresses, and none of that belongs in a blog post.
The numbers
| Receipts measured | 632 |
| Distinct typeface families | 24 |
| Distinct family combinations | 176 |
| Distinct type-size ranges (smallest to largest on one receipt) | 545 |
And the most common families, by share of receipts they appear on:
| Typeface | Share of receipts |
|---|---|
| Libre Barcode 39 | 35.1% |
| Thermal Receipt1 | 31.3% |
| Arvo | 23.9% |
| Thermal Receipt2 | 16.1% |
| Lekton | 14.6% |
| Open Sans | 11.1% |
| Roboto | 9.3% |
| B612 Mono | 7.8% |
Number one is a font nobody has ever read a word of. Libre Barcode 39's glyphs are barcode bars, and in the source data barcodes are stored as text set in it, so a barcode wins a font ranking.
Third place surprised me more. Arvo is a slab serif, and it's on nearly a quarter of all receipts. My best guess is that its chunky strokes are a decent stand-in for the slightly smeared output of a worn thermal print head.
176 combinations across 632 receipts works out to a new pairing roughly every three and a half receipts. And there are almost as many distinct size ranges as there are receipts. They all look the same from a metre away and differ a lot up close.
One confession: our own data has a typo in it. "Thermal Receipt2" and "ThermalReceipt2" (4.7%, just below the cut) are the same face spelled two ways, so 24 is the count of family strings, and the real number of typefaces is slightly lower. I'd rather show you that than quietly merge it.
Bonus fact while we were measuring: the median receipt is exactly three times as tall as it is wide.
Monospace is the layout engine
A receipt is a two-column table. Item on the left, price on the right. The printer doesn't measure text, and with a fixed-pitch font it doesn't need to: every character is one column, so if every line is the same number of characters, the prices line up on their own.
If you're generating text for an actual receipt printer, that's all your layout code is. Pad strings:
const line = (left, right, cols = 32) =>
left.slice(0, cols - right.length - 1).padEnd(cols - right.length) + right;
line('FLAT WHITE', '3.80');
// "FLAT WHITE 3.80"
line('SOURDOUGH LOAF, SLICED, EXTRA LONG NAME', '6.50');
// "SOURDOUGH LOAF, SLICED, EXT 6.50"
The slice matters. Without it, a long item name pushes its price onto the next line and the whole column falls apart, which is exactly what a real receipt does when a POS system gets a product name longer than anyone planned for.
Doing it in CSS when the font isn't monospaced
Here's the bit the data made clear: nearly half the receipts mix in a proportional face. Open Sans, Roboto and Raleway are all in the top dozen. Look at a real receipt and they're in the store name, the header and the "thanks for shopping" footer. The fixed-pitch constraint only applies to the money in the middle.
On a screen you don't have to pad anything. Something like this gets you most of the way:
<div class="receipt">
<h1 class="store">Corner Bakery</h1>
<div class="row"><span>Flat white</span><span class="amt">3.80</span></div>
<div class="row"><span>Croissant</span><span class="amt">2.90</span></div>
<div class="row"><span>Sourdough loaf</span><span class="amt">6.50</span></div>
<div class="row total"><span>Total</span><span class="amt">13.20</span></div>
</div>
.receipt {
width: 32ch; /* ch = the width of "0", so in a monospace font: 32 columns */
font: 14px/1.3 'Roboto Mono', monospace;
}
.store {
margin: 0 0 1em;
font: 700 20px 'Arvo', serif;
text-align: center;
}
.row {
display: flex;
justify-content: space-between;
gap: 1ch;
}
.amt {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.total {
border-top: 1px dashed;
font-size: 16px;
}
Two things worth knowing. tabular-nums only does anything if the font ships tabular figures (the tnum OpenType feature), so check before you set your prices in some display face and expect the decimals to line up. And notice there are three sizes: store name, body, total. Receipts don't really have a type scale, they have those three tiers, and every design sets them a little differently (hence 545 distinct size ranges).
The barcode: font or SVG?
Since a third of our receipts use a barcode font, it's tempting to just do the same:
<span style="font: 48px 'Libre Barcode 39'">*A-1234*</span>
It works, with two catches. Code 39 needs the asterisks: they're the start and stop characters, and the Libre Barcode docs tell you to wrap your text in them. Leave them off and you get bars that look fine and won't scan. And plain Code 39 is uppercase letters, digits, space and - . $ / + %. Anything else needs the Extended variant or a different symbology.
In the editor we draw barcodes with JsBarcode into an SVG instead, which also gives you Code 128, EAN-13, UPC and friends:
import JsBarcode from 'jsbarcode';
JsBarcode(svgElement, 'A-1234', {
format: 'CODE39',
displayValue: false,
margin: 0,
});
Four silent bugs between the browser and the PNG
Exports are rendered on the server with @napi-rs/canvas, and they have to match what the browser showed pixel for pixel. None of these four throw an error. Your PNG just quietly looks wrong.
1. The name inside the font file isn't the name you use. The two thermal fonts are referenced as "Thermal Receipt1" and "Thermal Receipt2". Ask the files what they're called:
$ fc-scan --format "%{family}\n" ThermalReceipt1.ttf
DotLatin_D31
Register the file without an alias, ask canvas for "Thermal Receipt1", and it falls back to a default font without a word. Pass the name you actually use:
import { GlobalFonts } from '@napi-rs/canvas';
GlobalFonts.registerFromPath('fonts/ThermalReceipt1.ttf', 'Thermal Receipt1');
2. Some fonts name every weight as its own family. This one's my favourite. The regular-weight Source Code Pro file from Fontsource:
$ fc-scan --format "%{family} / %{fullname}\n" source-code-pro-latin-400-normal.woff2
Source Code Pro ExtraLight / Source Code Pro ExtraLight Regular
The regular file says its family is "Source Code Pro ExtraLight". Ask for "Source Code Pro" and nothing matches. Aliasing fixes this too, and the weights survive because each file still carries its weight metadata.
3. letter-spacing adds a gap after the last character as well. In Chromium, abcde in a 20px monospace font measures 60px. Add letter-spacing: 10px and it measures 110px: five gaps, not four. If you measure text yourself, for a canvas renderer or a selection box, and count length - 1 gaps, every spaced line comes out one gap short and right-aligned text drifts. Count length.
4. An SVG and drawImage disagree about stretching. Give an <svg> with a viewBox a box of a different shape and the browser scales it to fit, centred, keeping its aspect ratio (preserveAspectRatio defaults to xMidYMid meet). drawImage into the same box stretches it to fill. Draw it naively and the exported barcode comes out stretched to fill the box. The server has to do the browser's maths:
const scale = Math.min(boxW / img.width, boxH / img.height);
const w = img.width * scale;
const h = img.height * scale;
ctx.drawImage(img, x + (boxW - w) / 2, y + (boxH - h) / 2, w, h);
What I'd tell myself before starting
- Use monospace for the columns with money in them, and anything you like for the header and footer.
- For a printer, pad strings. For a screen, flexbox and
tabular-nums. - Check a font's real family name with
fc-scanbefore you register it on a server. -
letter-spacinggaps = number of characters.
The full tables (all twelve top typefaces, element counts and the height-to-width bands) are in the write-up of our 632-receipt typography measurement, and the aggregate figures are free to reuse.
Top comments (0)