Four ways to make a share image.
the short answer
A share image is either drawn in the visitor's browser or drawn on a server, and that fork decides everything else. A custom element for share images belongs to the first branch: the tag renders the card in the page, and the same tag exports it as a PNG when someone clicks. The second branch, satori, or @vercel/og wrapping it, draws the image without a browser, which is the only way an Open Graph card can work, because the crawler that fetches it will never run your JavaScript.
So the question is not which library is better. It is whether the person the image is for is present. A user staring at their listening history and wanting a PNG of it is present, and the browser already has the receipt laid out. A social crawler asking for a preview card is not present, and there is no layout to reuse.
the size difference, and why
The client-side packages are much smaller than the server ones, 186 KB and 315 KB unpacked against 5.85 MB and 7.75 MB. That gap is structural rather than a matter of care. Rendering in a browser means borrowing the layout engine that is already in the room, which is what the <foreignObject> technique actually does. Rendering on a server means there is no layout engine to borrow and one has to be shipped.
route · version, deps, licence, size, published · when it wins
a custom element | one script tag · 0 deps · MIT | The element renders the thing AND exports it. Right when the user is looking at the artefact and wants a PNG of it on click. Tearline is 22,766 bytes served, HTTP 200 on 17 September 2026. |
modern-screenshot | 4.7.0 · 0 deps · MIT · 186 KB · 16 Apr 2026 | A rasteriser you point at any node you already have on screen. Right when the share image is an existing part of the page rather than a purpose-built card. |
html-to-image | 1.11.13 · 0 deps · MIT · 315 KB · 14 Feb 2025 | Same job, longer-standing. Also zero declared runtime dependencies. Both of these leave the markup, the styling and the click handler to you. |
satori | 0.33.4 · 13 deps · MPL-2.0 · 5.85 MB · 24 Aug 2026 | Renders the image on a server, so no browser is involved and no user has to be present. Right for an Open Graph card, which a crawler has to fetch without running your app. |
@vercel/og | 1.0.2 · 2 deps · MPL-2.0 · 7.75 MB · 24 Aug 2026 | The same server route packaged for a framework route handler. Its two declared runtime dependencies are satori and @resvg/resvg-wasm, so the licence is MPL-2.0 here too, not MIT. |
Registry facts, not a review: re-read from registry.npmjs.org on 9 September 2026. @vercel/og is 1.0.2, published 24 August 2026, with the same two runtime dependencies and 7.75 MB unpacked. One licence detail worth catching before it reaches a legal review, the two client-side rasterisers are MIT, and both server-side packages are MPL-2.0. @vercel/og declares exactly two runtime dependencies, satori and @resvg/resvg-wasm, which is why the licence carries through. Nothing here is a claim about how any of these four work internally: their metadata was fetched, their source was not.
The tag can ship before the script does.
upgrade, quoted
The strongest argument for making a share-image widget a custom element rather than a framework component is a guarantee written into the HTML Standard, and it has a name: upgrade. An element whose definition has not loaded yet is not an error. The standard walks through a script marked async placed after the tag: while the script is loading, “the img-viewer element will be treated as an undefined element, similar to a span”, and once it loads, “the existing img-viewer element on the page will be upgraded, applying the custom element’s definition”.
For a share image that is the whole game. The card can be in the server-rendered HTML, in a CMS field, in a Markdown file, in an email template someone else owns, and the script that turns it into an exportable receipt can arrive late, out of order, or from a CDN. Nothing has to co-ordinate. A framework component cannot make that promise, because the markup does not exist until the framework has booted.
the one caveat, also quoted
The standard is explicit that this has a boundary: “upgrades only apply to elements in the document tree”, formally, elements that are connected, and “an element that is not inserted into a document will stay un-upgraded”. So a share card built in memory and held there has no methods on it. Insert it, then export it.
what the boundary also buys
Two more things follow from the element boundary rather than from any particular implementation. The first is that the content stays real content. Text wrapped in a custom element sits in the light DOM, so it is selectable, searchable, translatable and read by a screen reader in document order, which a canvas-drawn or server-drawn card cannot offer, because a PNG has no text in it at all.
The second is that the styling is negotiable. Rules inside a shadow tree lose to ordinary rules on the host page, so a component's own look is a default rather than a fight, the mechanism, and the exact cascade wording, are in the receipt-UI write-up.
and what it does not buy
Being a custom element does nothing about the export sandbox. Any client-side route, element or library, hits the same two failures: a remote image or webfont will not load inside the serialised SVG, and cross-origin data drawn onto the canvas taints it so the pixels cannot be read back. Both are written up with their sources on the DOM-to-PNG page.
What the element has to implement.
a valid custom element name, all five must hold
a valid element local name | The base requirement. Per the standard, this “ensures the custom element can be created with createElement()”. |
starts with a lowercase letter | “name’s 0th code point is an ASCII lower alpha”, which “ensures the HTML parser will treat the name as a tag name instead of as text”. |
no capitals anywhere | “name does not contain any ASCII upper alphas”, so that a user agent “can always treat HTML elements ASCII-case-insensitively”. |
contains a hyphen | “name contains a U+002D (-)”, for namespacing and forward compatibility, no hyphenated local names will be added to HTML, SVG or MathML going forward. |
not one of eight reserved names | annotation-xml, color-profile, font-face, font-face-src, font-face-uri, font-face-format, font-face-name, missing-glyph. All hyphenated names that already exist in SVG or MathML. |
Quoted from the HTML Standard's custom-elements section, fetched 5 August 2026. The hyphen requirement is the one people trip over: sharecard is not a legal custom element name and share-card is. Get it wrong and the registration throws rather than failing quietly.
share-card.js
1// the whole contract for a share-image element2class ShareCard extends HTMLElement {3 static observedAttributes = ['width', 'seed'];45 attributeChangedCallback() { this.render(); }6 connectedCallback() { this.render(); }78 // the part that makes it a SHARE-image element9 async toBlob({ scale = 2 } = {}) { /* ... */ }10 async download(name) { /* ... */ }11}1213customElements.define('share-card', ShareCard);
Beyond the name, a share-image element needs three things. A static observedAttributes array, so that attributeChangedCallback() fires when the card's inputs change; a connectedCallback(), because that is the point at which the element is in the document and can measure itself; and an export method that returns a Blob rather than triggering a download, so the caller can upload it, put it on the clipboard or hand it to the Web Share API instead.
Determinism is the non-obvious requirement. If the card has any randomised element, a texture, a torn edge, a rotation, it has to be seeded, or the exported PNG will not match the card the user was looking at when they clicked. Tearline takes a seed attribute for exactly this reason.
Where this one fits. And where it does not.
what Tearline is
Tearline is a worked example of the first row in that table, with one strong opinion: it only makes one kind of picture. Wrap markup in <tear-line> and it renders as a thermal receipt, paper texture, dashed rules, seeded torn edge, barcode, and the element exports that. One script tag, no build step, no runtime dependencies, 22,766 bytes served unminified at /tearline.js and HTTP 200 on 5 August 2026.
The full attribute and method reference is in the documentation. A worked build of the genre most people arrive looking for, a listening-history receipt, and the API cap that stops most of them shipping, is a separate write-up.
when to use something else
Three cases, stated plainly. If the share image needs to be an Open Graph card that a crawler fetches, no client-side element can do it and the server route is the answer. If the share image should look like anything other than a receipt, this component is the wrong shape and a general rasteriser pointed at your own markup is the right one. And if the requirement is a PNG of some part of the page that already exists, modern-screenshot or html-to-image is a closer fit than any purpose-built element.
Read it before you adopt it, because the component is served unminified at /tearline.js and published as @compoundlabs/tearline on npm. The package is MIT-licensed, has zero dependencies, and the hosted file is the readable source; there is no minified build hiding a second implementation. It installs from npm or from the script tag above, while npm refuses the bare name tearline as too close to readline, so the package is scoped.
Every number here. Fetched, not remembered.
checked 5 August 2026
registry.npmjs.org | Latest version, licence, declared runtime dependency count, publish date and unpacked size for satori, @vercel/og, modern-screenshot and html-to-image. Re-fetched 9 September 2026: satori and modern-screenshot held; @vercel/og had moved to 1.0.2, 7.75 MB unpacked, published 24 August 2026. |
html.spec.whatwg.org | The five requirements for a valid custom element name, and the upgrade behaviour quoted below, both from the HTML Standard's custom-elements section. Fetched 5 August 2026. |
tearline.thecompound.tech/tearline.js | The served component: 22,766 bytes, unminified, HTTP 200 on 17 September 2026. It is the whole thing; there is nothing else to read. |
why this is here
Package versions, licences and sizes go stale, and a page that quotes them from memory is wrong within weeks without ever looking wrong. Every figure above carries the date it was read, so you can tell at a glance how much to trust it, and so can we, because scripts/verify-compare.mjs re-fetches these same sources and fails when a figure no longer matches what the registry returns.
For the client side half of this fork set out in full, every option on one grid, read the register of every DOM to image option.
The spec quotations are from the WHATWG HTML Standard itself rather than from a summary of it, because it is a living standard and a summary is a snapshot of one. If a number here has drifted, the source 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.