<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Faizan Umer</title>
    <description>The latest articles on DEV Community by Faizan Umer (@faizanumer7).</description>
    <link>https://dev.to/faizanumer7</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4101655%2Fe77ef14e-39b1-4130-a118-c610e1cd40bc.jpg</url>
      <title>DEV Community: Faizan Umer</title>
      <link>https://dev.to/faizanumer7</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/faizanumer7"/>
    <language>en</language>
    <item>
      <title>I Built a "Brat" Style Text Generator With Vanilla JS and Canvas (No Backend)</title>
      <dc:creator>Faizan Umer</dc:creator>
      <pubDate>Sun, 30 Aug 2026 17:32:38 +0000</pubDate>
      <link>https://dev.to/faizanumer7/i-built-a-brat-style-text-generator-with-vanilla-js-and-canvas-no-backend-3kgf</link>
      <guid>https://dev.to/faizanumer7/i-built-a-brat-style-text-generator-with-vanilla-js-and-canvas-no-backend-3kgf</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The core idea: everything happens in the browser&lt;/p&gt;

&lt;p&gt;There's no backend. No image upload to a server, no processing queue, nothing stored anywhere. The entire flow is:&lt;/p&gt;

&lt;p&gt;User types text into a &amp;lt;br&amp;gt;
JavaScript draws that text onto an HTML5 &amp;lt;canvas&amp;gt; with the right font, color, and background&amp;lt;br&amp;gt;
canvas.toDataURL() (or toBlob()) turns the canvas into a downloadable PNG&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;That's genuinely most of the tool. The rest is UX details that make it feel like a real product instead of a demo.&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;Size presets instead of "type your own resolution"&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;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:&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;js&amp;lt;br&amp;gt;
const SIZE_PRESETS = [&amp;lt;br&amp;gt;
  { id: 'ig-post', label: 'Instagram Post (1080 x 1080)', w: 1080, h: 1080 },&amp;lt;br&amp;gt;
  { id: 'ig-story', label: 'Instagram Story (1080 x 1920)', w: 1080, h: 1920 },&amp;lt;br&amp;gt;
  { id: 'yt-thumb', label: 'YouTube Thumbnail (1280 x 720)', w: 1280, h: 720 },&amp;lt;br&amp;gt;
  // ...more&amp;lt;br&amp;gt;
];&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;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.&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;Style presets are just bundles of font settings&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;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:&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;js&amp;lt;br&amp;gt;
const STYLE_PRESETS = {&amp;lt;br&amp;gt;
  classic: { fontFamily: 'Arial Narrow, sans-serif', bold: false, letterSpacing: -1 },&amp;lt;br&amp;gt;
  // ...more&amp;lt;br&amp;gt;
};&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;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.&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;Keeping text input honest with the canvas&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;The &amp;lt;textarea&amp;gt; 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.&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;There's no CSS letter-spacing on a canvas&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;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.&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;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:&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;js&amp;lt;br&amp;gt;
function drawTextWithSpacing(ctx, text, x, y, spacing) {&amp;lt;br&amp;gt;
  let cursorX = x;&amp;lt;br&amp;gt;
  for (const char of text) {&amp;lt;br&amp;gt;
    ctx.fillText(char, cursorX, y);&amp;lt;br&amp;gt;
    const charWidth = ctx.measureText(char).width;&amp;lt;br&amp;gt;
    cursorX += charWidth + spacing;&amp;lt;br&amp;gt;
  }&amp;lt;br&amp;gt;
}&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;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.&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;Adding a background image without a canvas library&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;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:&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;js&amp;lt;br&amp;gt;
function drawCoverImage(ctx, img, canvasW, canvasH) {&amp;lt;br&amp;gt;
  const scale = Math.max(canvasW / img.width, canvasH / img.height);&amp;lt;br&amp;gt;
  const drawW = img.width * scale;&amp;lt;br&amp;gt;
  const drawH = img.height * scale;&amp;lt;br&amp;gt;
  const offsetX = (canvasW - drawW) / 2;&amp;lt;br&amp;gt;
  const offsetY = (canvasH - drawH) / 2;&amp;lt;br&amp;gt;
  ctx.drawImage(img, offsetX, offsetY, drawW, drawH);&amp;lt;br&amp;gt;
}&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;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.&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;The uploaded image never leaves the browser tab either — it's read with FileReader straight into an &amp;lt;img&amp;gt; element that gets drawn onto the canvas locally, so there's still no upload endpoint anywhere in this feature.&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;Export is just canvas -&amp;gt; blob -&amp;gt; download link&amp;lt;br&amp;gt;
js&amp;lt;br&amp;gt;
canvas.toBlob((blob) =&amp;gt; {&amp;lt;br&amp;gt;
  const url = URL.createObjectURL(blob);&amp;lt;br&amp;gt;
  const a = document.createElement('a');&amp;lt;br&amp;gt;
  a.href = url;&amp;lt;br&amp;gt;
  a.download = 'brat-image.png';&amp;lt;br&amp;gt;
  a.click();&amp;lt;br&amp;gt;
  URL.revokeObjectURL(url);&amp;lt;br&amp;gt;
}, 'image/png');&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;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.&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;What I'd do differently&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;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.&amp;lt;/p&amp;gt;

&amp;lt;p&amp;gt;If you're curious, the live tool is at &amp;lt;a href="https://ezainfozone.com/"&amp;gt;ezainfozone.com&amp;lt;/a&amp;gt;, happy to answer questions about the canvas/export approach in the comments.&amp;lt;/p&amp;gt;
&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>beginners</category>
    </item>
  </channel>
</rss>
