DEV Community

Cover image for I Built a "Brat" Style Text Generator With Vanilla JS and Canvas (No Backend)
Faizan Umer
Faizan Umer

Posted on

I Built a "Brat" Style Text Generator With Vanilla JS and Canvas (No Backend)

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)