Why I Built Tweet to Image — and the Detail That Almost Killed It
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.
- The column is 598px on X’s desktop web, not “about 600”. Twenty pixels of extra width changes the line breaks in a way you feel before you see.
- The avatar is 40px, not 48. Forty-eight is what you guess, and 48 makes the card look like a support ticket.
- The display name, verified badge, @username and timestamp all sit on one line, with the display name truncating before the username does. Stack them and it reads as a comment on a blog rather than a post on X.
- The action bar sits under the text with a specific amount of air above it, and its icons are smaller than they feel like they ought to be. Get the spacing wrong and the card looks bottom-heavy.
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:
- Long URLs are truncated for display, the way X truncates them, rather than running to the edge of the card.
- Blank lines between paragraphs survive exactly as typed. X preserves them; most rebuilds collapse them, and collapsed paragraphs are the second-biggest tell after zeros. How the tweet text is handled covers what else the body field does on its own.
- The timestamp is free text, not a date picker. You are describing what the card should show — “14h”, “3h”, “Sep 6”, “14小时” — not when something was posted. A date picker would have been more code and less useful.
- Three themes, because Dim (#15202B) exists and a surprising number of people live in it. A card in the wrong dark is as wrong as a card in the wrong font.
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.
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.
- Find the four numbers. Every “it looks off” is a small set of specific wrong values. You can find them with a screenshot and a ruler in an hour, and you will otherwise spend a week guessing.
- Ship the honest claim, not the aspirational one. It is faster, it ages better, and it is the only version you can still defend in a year.
- If the thing you built can be misused, say so on the page. Do the one concrete thing you can actually do about it, and be equally clear about what that does not cover.
- Render your HTML at build time. The crawlers that matter most now are the ones that do not run JavaScript.
The version that came out of all this is on the home page, and it is free.
The settings behind this
The sections of the guide this post relies on:
- How do you set the engagement numbers?
- How is the tweet text handled?
- How do you set the account details?
Keep reading
- How to Screenshot a Tweet on Any DeviceThe gesture takes two seconds; making the result publishable is the part nobody writes about. Here is the exact shortcut on every device, the browser trick that captures a single tweet at any resolution you like, and the six things that make a raw screenshot look wrong in a slide or a feed.
- Tweet Screenshots for Instagram: Sizes, Crops and a Workflow That ScalesInstagram displays at 1080px wide, crops the feed to 4:5, and re-encodes everything you upload as JPEG. That is three constraints a phone screenshot of a tweet fails all at once. Here are the numbers that work, why a 2× export is exactly the right size, and how to keep a twelve-slide carousel looking like one set.