← All posts

Why I Built Tweet to Image — and the Detail That Almost Killed It

· 7 min read

I built this because I needed one tweet in one slide and spent forty minutes not getting it. That is the entire origin story. The rest of this is what happened afterwards, including the week I came close to deleting the repository.

The forty minutes

A three-line tweet, a slide, and every route to getting one into the other being slightly wrong.

I took a screenshot on my phone. It was 1170px of phone, of which the tweet was maybe half, wrapped in my own timeline: the reply box underneath, the top of the next post, my avatar in the corner, the battery at 23%.

So I cropped it. Now it was 900px of soft grey text on a white rectangle, sitting on a slide with a dark background, inside a white box I could not get rid of. I tried again on my laptop, which was sharper, and still a white box. I opened Figma and started rebuilding the card by hand — name, handle, timestamp, that little row of icons.

That is the moment I stopped. If I was going to lay this out by hand once, I was going to lay it out by hand every time, and I would get it slightly different every time. So I closed Figma and wrote the first version of this instead.

The first version worked, and it was wrong

It rendered a tweet in an afternoon. It also looked, unmistakably, like a drawing of a tweet.

Everyone I showed it to said the same useless, correct thing: “it looks off.” Nobody could say why, including me. It took longer than I would like to admit to work out that “off” was four separate numbers.

An annotated tweet card showing the four measurements that decide whether it looks real: a 598px column, a 40px avatar, one line for name, badge, handle and time, and the action bar beneath the text. 40px avatar name · badge · @handle · time all on one line action bar; 0 hides the number 598px column — not “about 600”
None of these are hard to get right. All of them are invisible until they are wrong, which is why “it looks off” is such expensive feedback.

The week it almost failed

I could not match the font, and for a few days I thought that meant there was no product here.

X sets body text in a proprietary typeface. I cannot bundle it, I am not going to ship something claiming to be pixel-perfect while the letterforms are visibly not the same, and there is no clever way around that. For about a week the project sat there being 90% right in a way that made the remaining 10% unbearable.

Then I looked at what happens with Chinese. X’s body face has no CJK glyphs at all — which means Chinese on X is *already* falling through to the system font: PingFang SC on Apple devices, Noto Sans SC elsewhere. If I used the same fallback chain, Chinese posts would not be approximately right. They would be the same rendering, because the browser is doing exactly what the browser on X is doing.

That reframed the whole project. The honest claim was never “pixel perfect”. It was: as close as a browser can get, and exactly right in the places where the browser is the one deciding. So I changed the claim rather than the product, wrote the limitation into the about page where anyone can read it, and shipped.

I think this is the most useful thing in this post. The failure was not technical. It was that I had promised myself a claim I could not keep, and the fix was to stop making the claim — not to keep grinding at the font.

The detail that took longest and mattered most

Zero counts. On X, a post with no reposts does not show a zero — it shows the icon and nothing else.

My first version rendered a proud 0 under every card, and it was the single biggest tell — the engagement fields now treat 0 as “hide the number”. Once that was fixed, the same class of detail kept turning up:

The part I am least comfortable with

A tool that makes believable tweet cards makes believable fake tweet cards. There is no version of this project where that is not true.

I thought about not shipping. The argument against that is unsatisfying but I believe it is correct: the capability already exists in every screenshot tool, every image editor and roughly forty other websites. Withholding this one does not remove the capability. It only removes the version that is trying to behave.

So here is what it actually does. Every exported PNG carries a note in its file metadata identifying it as a synthetic image. It is invisible, it does not touch a single pixel of the picture, and any standard image tool can read it back. The tool does not fetch anything from X — you type the text in yourself, so there is no “paste a link, get a perfect copy of a real post” path, which is the path that would make fabrication at scale easy. And the terms prohibit impersonation and prohibit stripping the metadata.

And here is the limit, because saying only the first part would be dishonest: metadata is strippable. Anyone determined can remove it with one command. It is a speed bump and a statement of intent, not a guarantee. I would rather ship a speed bump and describe it accurately than claim a guarantee I have no way to enforce.

Why there is no server

Because the alternative is holding a database of other people’s words, and I do not want it.

Everything renders in your browser. The card is ordinary HTML and CSS; the export is a capture of that same page. There is no image server and no API call anywhere in the export path.

The consequences are almost all good ones. It keeps working with the network off once the page has loaded. It costs essentially nothing to run, which is why it can be free without a plan to make it not-free later. It cannot leak what you typed, because what you typed never left the tab — which is also why the privacy policy is short: there is very little to disclose.

The cost is that browser differences are mine to deal with. Safari’s clipboard, for instance, requires that the write begin in the same task as your click, so the render has to be handed over as a promise rather than awaited first. Nobody will ever notice that this was a problem, which is exactly the point.

The second thing that almost failed

The site was a single-page app, so crawlers arrived and saw an empty div.

For a while the home page was pure client-side React. A person saw a full page. Googlebot mostly coped. GPTBot, ClaudeBot and PerplexityBot — which do not execute JavaScript — saw an empty root element and nothing else: no heading, no description, nothing quotable.

A tool that explains itself in prose no crawler can read is a tool nobody finds. The fix was to render the page to real HTML at build time, which is unglamorous, took an afternoon, and did more for how many people arrive than anything I did to the card itself.

Two views of the same home page: an empty root element, which is all a crawler that does not run JavaScript could see, and the same page rendered to real HTML at build time. Client-side rendering <div id="root"> </div> no heading, no text 0 words to quote Rendered at build time <h1>Tweet to Image … <p>A Twitter screenshot … <h2>What is Tweet to … the whole page, as HTML ~2,900 words of real markup
Googlebot mostly copes with client-side rendering. GPTBot, ClaudeBot and PerplexityBot do not execute JavaScript at all, so build-time rendering is the difference between being quotable and being invisible to them.

What I will not add

Threads, media, quote tweets, polls.

Not because they are hard. Because each one widens the surface of “believable fake” considerably. A single text card is a quotation with a face attached to it. A fabricated thread with images in it is a different kind of object, and I do not want to be the one who made that easy.

There is also a less noble reason: the single-card version is the one I needed for my slide, and it is finished. Finished tools are rare, and I would quite like to have made one.

If you are building something like this

Four things I would tell myself at the start.

The version that came out of all this is on the home page, and it is free.

Open the tool

The settings behind this

The sections of the guide this post relies on:

Keep reading