Back in 2024, the "brat" aesthetic (lowercase text, Arial Narrow, that specific lime green) went viral off Charli XCX's album cover, and a wave of "brat generator" tools popped up so people could make their own version for captions, covers, and posts. I ended up building one myself at ezainfozone.com, and I wanted to write up how it actually works under the hood, since it's a good small example of what you can get away with using just the Canvas API and no server at all.
The core idea: everything happens in the browser
There's no backend. No image upload to a server, no processing queue, nothing stored anywhere. The entire flow is:
User types text into a <br> JavaScript draws that text onto an HTML5 <canvas> with the right font, color, and background<br> canvas.toDataURL() (or toBlob()) turns the canvas into a downloadable PNG</p> <p>That's genuinely most of the tool. The rest is UX details that make it feel like a real product instead of a demo.</p> <p>Size presets instead of "type your own resolution"</p> <p>The first thing I learned from watching people actually use it: nobody wants to compute pixel dimensions in their head. So instead of a blank width/height field, the tool ships a list of named presets tied to real dimensions people already recognize:</p> <p>js<br> const SIZE_PRESETS = [<br> { id: 'ig-post', label: 'Instagram Post (1080 x 1080)', w: 1080, h: 1080 },<br> { id: 'ig-story', label: 'Instagram Story (1080 x 1920)', w: 1080, h: 1920 },<br> { id: 'yt-thumb', label: 'YouTube Thumbnail (1280 x 720)', w: 1280, h: 720 },<br> // ...more<br> ];</p> <p>Picking a preset just resizes the canvas element and re-renders the text at the new dimensions. A "Custom size" option is still there underneath for people who need something specific, with sane min/max bounds on the width/height inputs so people can't accidentally generate a 50000px canvas and lock up their tab.</p> <p>Style presets are just bundles of font settings</p> <p>The "Classic" look, and a few variations on it, aren't separate rendering code paths — they're just objects describing font weight, tracking, and whether the text is bold:</p> <p>js<br> const STYLE_PRESETS = {<br> classic: { fontFamily: 'Arial Narrow, sans-serif', bold: false, letterSpacing: -1 },<br> // ...more<br> };</p> <p>One thing this taught me early: I originally assumed the "iconic" look was bold text, and had to walk that back after actually reading the preset — it's a regular/lighter weight that just reads as bold at small sizes because of how tight the letter spacing is. Small thing, but it changed how I wrote the help copy for the tool later.</p> <p>Keeping text input honest with the canvas</p> <p>The <textarea> has a maxlength="120" and white-space: pre-wrap styling so line breaks the user types are preserved. But there's no per-line font sizing — one fontSize/lineHeight/letterSpacing setting applies to the whole block. That's a deliberate simplification: supporting independent sizing per line would mean building a mini text-layout engine, and for a tool whose whole point is "type a short phrase, get an image back fast," it wasn't worth the complexity. It does mean longer multi-line input (like an address, or a paragraph) needs the font size turned down manually, which is a limitation worth being upfront about rather than pretending the tool does something it doesn't.</p> <p>There's no CSS letter-spacing on a canvas</p> <p>This one tripped me up early. On a regular DOM element you'd just set letter-spacing: -1px and move on. CanvasRenderingContext2D has no equivalent property — ctx.fillText() just draws a whole string at once using the font's default spacing.</p> <p>So tight letter-spacing (which is a big part of why the "Classic" preset reads the way it does even at a light font weight) has to be done manually, character by character:</p> <p>js<br> function drawTextWithSpacing(ctx, text, x, y, spacing) {<br> let cursorX = x;<br> for (const char of text) {<br> ctx.fillText(char, cursorX, y);<br> const charWidth = ctx.measureText(char).width;<br> cursorX += charWidth + spacing;<br> }<br> }</p> <p>spacing is negative for the tighter presets, so each next character gets drawn slightly closer to the last one than its natural width would suggest. It's a small function, but it's doing more work than it looks like — measureText() gets called once per character per render, so on a slow device with a long line of text you can notice the cost. Debouncing the re-render while someone is actively typing (instead of redrawing on every keystroke) made a bigger practical difference than trying to micro-optimize the loop itself.</p> <p>Adding a background image without a canvas library</p> <p>Later on I added support for dropping in your own background image instead of a flat color. The tricky part isn't loading the image — it's drawImage() doesn't know anything about "cover" or "contain" the way CSS background-size does. You have to work out the scale and offset yourself:</p> <p>js<br> function drawCoverImage(ctx, img, canvasW, canvasH) {<br> const scale = Math.max(canvasW / img.width, canvasH / img.height);<br> const drawW = img.width * scale;<br> const drawH = img.height * scale;<br> const offsetX = (canvasW - drawW) / 2;<br> const offsetY = (canvasH - drawH) / 2;<br> ctx.drawImage(img, offsetX, offsetY, drawW, drawH);<br> }</p> <p>Taking the larger of the two scale ratios (width-to-width, height-to-height) and centering the result is the whole trick behind a "cover" fit — the image always fills the canvas completely, and whatever doesn't fit gets cropped off-center evenly on either side. Get the Math.max backwards and you'll get "contain" behavior instead, with letterboxing you didn't ask for.</p> <p>The uploaded image never leaves the browser tab either — it's read with FileReader straight into an <img> element that gets drawn onto the canvas locally, so there's still no upload endpoint anywhere in this feature.</p> <p>Export is just canvas -> blob -> download link<br> js<br> canvas.toBlob((blob) => {<br> const url = URL.createObjectURL(blob);<br> const a = document.createElement('a');<br> a.href = url;<br> a.download = 'brat-image.png';<br> a.click();<br> URL.revokeObjectURL(url);<br> }, 'image/png');</p> <p>No upload round-trip, no waiting on a server to process an image queue. The trade-off is that everything is bounded by what the user's own device can render, which for flat-color text on a canvas is basically a non-issue even on older phones.</p> <p>What I'd do differently</p> <p>If I rebuilt this today, I'd probably split font-size logic per line early on instead of bolting it on later, since a decent chunk of user requests end up being "can I fit a headline and a smaller detail line." I'd also bake in an aspect-ratio warning up front, since square-canvas-vs-rectangular-print-size mismatches are a common source of confused support questions.</p> <p>If you're curious, the live tool is at <a href="https://ezainfozone.com/">ezainfozone.com</a>, happy to answer questions about the canvas/export approach in the comments.</p>
Top comments (0)