The paper is CSS. There is no image.
what the look is made of
A receipt is one of the few UI looks that is entirely reachable with plain CSS, because everything that makes a receipt look like a receipt is a property that already exists. Narrow measure, monospace, small type on loose leading, dashed rules, tracked capitals. There is no illustration, no sprite and no font to license.
Four layers, in the order they matter. The paper is a background colour and a wide, uneven linear-gradient across the width, a warm falloff at both edges and a soft crease off-centre, which is what stops a flat rectangle reading as a card. The fibre is a single SVG feTurbulence as a data URI, laid over the whole element at mix-blend-mode: multiply and about a third opacity: it is grain, not texture, and at full strength it looks like a filter. The rules are ordinary <hr> elements with a dashed top border. The type does the rest, and it has its own section below.
the torn edge
The tear is a clip-path polygon, generated once. Fifty-eight steps across the top and the same across the bottom; each point sits a few pixels in, with roughly a one-in-six chance of a deeper nick. That ratio is the whole trick, paper ripped off a printer is mostly straight with occasional ragged bites, so an even zigzag reads as a decorative border rather than a tear.
Generate it from a seeded PRNG rather than from randomness. A receipt that reshuffles its own edge on every render is unsettling on screen, and, more practically, an export taken a frame later will not match the shape the reader was looking at.
receipt.css
1/* 1, the paper itself */2.receipt {3 width: 330px;4 padding: 34px 26px 30px;5 background-color: #f6f3ec;6 color: #2b2724;7 font-family: ui-monospace, Menlo, monospace;8 font-size: 11.5px;9 line-height: 1.62;10 letter-spacing: .04em;11 font-variant-numeric: tabular-nums;12}1314/* 2, the fibre: one turbulence, multiplied over */15.receipt::before {16 content: ""; position: absolute; inset: 0;17 opacity: .34; mix-blend-mode: multiply;18 background-image: url("data:image/svg+xml,…feTurbulence…");19}2021/* 3, the rules are ordinary <hr> elements */22.receipt hr {23 border: 0; margin: 13px 0;24 border-top: 1px dashed rgba(40,36,33,.42);25}2627/* 4, headings print in tracked caps */28.receipt h1 {29 text-align: center; text-transform: uppercase;30 letter-spacing: .20em; font-weight: 700;31}
These are the shipped values, read out of the component served at /tearline.js on 1 August 2026, not illustrative ones. One line is an addition rather than a quote: font-variant-numeric is not currently set by the component and should be. Copy the block above and you have the look without loading anything.
Monospace does most of the work.
property · value · why
font-family | a monospace stack | A thermal printer has a fixed character cell, so every receipt you have ever held is monospaced. This is the single decision that makes the look read; get it wrong and no amount of paper texture rescues it. |
font-variant-numeric | tabular-nums | Per MDN, tabular figures are the set where numbers are all the same width, so they align like a table. Prices in a right-hand column stop jittering line to line, the difference between a receipt and a list of numbers. |
letter-spacing | .04em body · .20em headings | Thermal heads over-ink slightly and the paper wicks, so real receipt type sits looser than screen type. Body gets a hair of tracking; the shop name gets a lot, which is what sells the caps. |
font-size | 11.5px, line-height 1.62 | Small type on a narrow measure. The generous leading is doing the work, a receipt is mostly whitespace between short rules, and tight leading reads as a terminal instead. |
text-transform | uppercase on headings only | Uppercase everything and it becomes unreadable rather than authentic. The header and the section labels are capitalised; the line items are not. |
text-shadow | 0 0 .55px, ink-coloured | A sub-pixel bloom in the ink colour. Thermal ink is never a crisp vector edge, and this is the cheapest approximation of that, it survives a 2× PNG export, where a blur filter would not. |
the one to get right
If you only take one line from this page, take font-variant-numeric: tabular-nums. Most monospace stacks give it to you already, but the moment someone overrides the font, and on a share image built from a listening history, a spend summary or a sports scoreline, someone always does, proportional figures come back and the right-hand column starts wobbling. MDN describes tabular figures as the set where numbers are all the same size so they align like a table, which is exactly the job.
what not to reach for
Skip the crumple-paper photograph, the drop-shadowed cardstock and the 3D fold. They are three different aesthetics wearing a receipt costume, and none of them survives being rasterised into a 1,200px-wide share image, the detail that sold the effect at full size turns to mush at export scale.
The same goes for a real barcode. A decorative row of varied bars reads correctly at a glance; a scannable Code 128 that encodes nothing meaningful invites someone to scan it and file a bug. Vary the bar widths, print digits underneath, and say in your docs that it is decoration.
One tag. Why a custom element.
what the platform gives you for free
a hyphen in the name | MDN: the name "must start with a lowercase letter, contain a hyphen, and satisfy certain other rules". This is what keeps custom elements from ever colliding with a future built-in tag, and it is why every share-image widget you have seen is <something-something>. |
observedAttributes | A static array of the attributes you want change notifications for. MDN notes that if the element's HTML declaration includes an observed attribute, attributeChangedCallback() fires after the attribute is initialised, when the declaration is first parsed, so the same code path handles the first render and every later change. |
attachShadow | The look goes in a shadow tree and stops there: page CSS does not reach into it, and its CSS does not leak out. For a widget that has to look identical on someone else's site, that is the entire point. |
the light DOM stays real | Slotted content is still your markup, in the document, in order. It stays selectable, searchable, translatable and readable by a screen reader, which a <canvas> or an <img> is not. The picture is the export, not the page. |
A share-image widget is the case custom elements were designed for. It has a hard visual contract, it is dropped into pages whose CSS you will never see, and it has to work the same in React, Vue, Svelte, Astro and a static file with a script tag. A framework component gives you one of those; a custom element gives you all five, because it is just HTML.
element.js
1class Receipt extends HTMLElement {2 static observedAttributes = ['width', 'seed'];34 constructor() {5 super();6 this.attachShadow({ mode: 'open' });7 }89 // fires on first parse, not just on change10 attributeChangedCallback() { this.paint(); }1112 // the export lives ON the element13 async toBlob({ scale = 2 } = {}) { /* … */ }14}1516customElements.define('tear-line', Receipt); // the hyphen is required
encapsulation is not a cage
The usual objection to shadow DOM is that users cannot restyle it. For slotted content that is backwards, and the spec says so: in CSS Cascade 5's sorting order, when two declarations come from different encapsulation contexts, the outer context wins for normal rules and the inner context wins for important ones. So a ::slotted() rule inside the component is a default that any ordinary rule on the host page beats, with no specificity fight and no !important arms race. Style the parts you want; the rest stays styled.
The other reason to package it this way: the export belongs on the element. A component that renders the picture and a separate library that rasterises it are two things to keep in sync, and the second one has to be told how to find the first. Put toBlob() on the element and the widget owns its own output. How that export actually works, and the two ways it fails, is written up at export a DOM element as a PNG.
There is a second argument for the element boundary that is stronger than either of those, because it is a guarantee written into the HTML Standard rather than a matter of taste: an element whose defining script has not loaded yet is not an error, and gets upgraded in place when the script arrives. Which means the tag can sit in server-rendered HTML, a CMS field or someone else's template while the script loads late. That, and the client-versus-server fork behind any share image, is compared separately.
Every claim here. Fetched, not remembered.
checked 1 August 2026
developer.mozilla.org, Using custom elements | The valid-name rule (lowercase start, must contain a hyphen), customElements.define(), and the timing of attributeChangedCallback() on first parse. Fetched 1 August 2026. |
w3.org, CSS Cascade and Inheritance Level 5 | The tree-context criterion in cascade sorting order: between encapsulation contexts, the declaration from the outer context wins for normal rules, and the inner context wins for important rules. Fetched 1 August 2026. |
developer.mozilla.org, font-variant-numeric | tabular-nums "activating the set of figures where numbers are all of the same size, allowing them to be easily aligned like in tables", mapping to the OpenType tnum feature. Fetched 1 August 2026. |
tearline.thecompound.tech/tearline.js | The shipped implementation every measurement on this page was read out of, the paper CSS, the seeded tear polygon and the element class. 22,766 bytes, unminified, HTTP 200 on 17 September 2026. |
and the working version
Everything above is buildable from scratch, that is the point of writing it out. If you would rather not, Tearline is the same technique as one tag: wrap your markup in <tear-line> and it renders as the receipt and exports itself as a PNG. Zero dependencies, no build step, MIT. The full attribute and method reference covers the rest, and the playground on the home page edits a live one.
Spec behaviour decays more slowly than a version number, but it does decay, cascade rules get revised and browser support moves. Each claim above carries the date it was read, and the source link is the authority, not this page.
WHEN THE ELEMENT CHANGES
Tearline is one custom element with six attributes, three methods and no dependencies, served as an MIT package. Attributes get added and the export stages change with them. Leave an address and Tearline writes when a release changes the API.