How I Built a Browser-Only Bubble Text Generator with SVG and Canvas

Visual text generators look simple from the outside: type a phrase, choose a style, and download an image.

The implementation gets more interesting when the whole pipeline has to stay in the browser. The preview must match the exported PNG, fonts must render consistently across devices, transparent margins should be cropped, and changing a slider should not make the layout jump.

I recently built a small browser-based text-art generator to explore that problem. This post explains the architecture and the details that took the most work.

What I wanted the tool to do

The product requirements were intentionally narrow:

  • Accept a short phrase, including line breaks and mixed case
  • Offer three visibly different font shapes
  • Apply eight finishes such as glossy, sugar, holographic, chrome, neon, and sticker
  • Let the user change the font size and color
  • Preview changes immediately
  • Export a watermark-free PNG
  • Keep the phrase and rendering work inside the browser
  • Avoid accounts, uploads, and a server-side image API

The last requirement shaped almost every technical decision.

The rendering pipeline

The editor is a Client Component in a Next.js App Router project. React owns the controls, but the artwork itself is created by a pure TypeScript function.

The pipeline looks like this:

React controls
  -> SVG string
  -> SVG Blob
  -> HTML Image
  -> Canvas raster
  -> alpha-bound scan
  -> cropped PNG Blob

Keeping SVG generation in a pure function made it easier to test every combination without a browser. The browser-specific work is limited to font loading, text measurement, preview rasterization, and downloading.

Why SVG was a good fit

SVG gives text effects a useful middle ground between plain CSS and a full graphics engine.

Each finish is built from three text layers:

  1. An outer stroke and shadow
  2. The main gradient-filled text
  3. A thin highlight shifted slightly upward and left

A simplified version looks like this:

<g text-anchor="middle" dominant-baseline="middle">
  <text
    fill="url(#gummyFill)"
    stroke="#8a174e"
    paint-order="stroke fill"
    filter="url(#softShadow)">
    HELLO
  </text>

  <text
    fill="url(#gummyFill)"
    stroke="#ff4fa3"
    paint-order="stroke fill">
    HELLO
  </text>

  <text
    fill="none"
    stroke="#ffffff"
    transform="translate(-2 -4)">
    HELLO
  </text>
</g>

The styles share the same renderer. Only a finish profile changes the gradient definitions, texture filter, stroke policy, and outer filter.

That is important because it prevents style logic from being scattered across the codebase. Adding a new finish means defining one complete rendering profile rather than updating several conditionals.

Making fonts consistent across devices

Relying on system font stacks caused an early problem. A rounded font and a chunky font can look different on one desktop and collapse to similar fallback fonts on another device.

I fixed that by bundling the font files with the application.

When a user selects a shape, the browser:

  1. Fetches the matching TTF file
  2. Waits for the browser font to load
  3. Converts the file to a data URL
  4. Embeds it into the generated SVG with @font-face

That makes the SVG self-contained before it is rasterized. The preview and the downloaded PNG therefore use the same font data instead of depending on whatever fonts happen to be installed on the device.

Measuring text before sizing the canvas

Character count is not a reliable width measurement. A string full of W characters can be much wider than a string of i characters with the same length.

The renderer uses CanvasRenderingContext2D.measureText with the selected family, weight, style, size, and letter spacing. The widest line determines the SVG canvas width.

The canvas is then sized around the measured artwork:

const widestLine = Math.max(
  fontSize,
  ...lines.map((line) => measureLine(line, fontSize)),
)

const padding = Math.max(24, fontSize * 0.22)
const width = Math.round(widestLine + padding * 2)
const height = Math.round(
  lines.length * fontSize * 0.94 + padding * 2,
)

This avoids two opposite problems:

  • Long phrases getting clipped
  • Short phrases being exported inside a huge fixed square

It also means a larger font size produces a genuinely larger PNG instead of merely scaling the same fixed canvas.

Keeping the preview stable

A tightly sized export is useful, but a tightly sized live preview can be distracting. The preview frame would change width on every keystroke and every font-size adjustment.

I use two SVG renders from the same source options:

  • The preview SVG uses a frame sized for the maximum font size
  • The download SVG is sized tightly around the selected font size

The text effect is identical, but the preview has a stable visual frame. The exported file remains compact.

The preview raster is capped at a smaller resolution for responsiveness, while export can use a larger maximum side. Neither path upscales the artwork beyond its natural dimensions.

Auto-cropping the PNG

After loading the SVG into an Image element, the browser draws it onto a temporary canvas. For transparent exports, the code reads the alpha channel and finds the first and last nontransparent pixel on each axis.

A safe margin is added to those bounds so shadows and glow effects are not cut off. The selected rectangle is then copied to a second canvas and encoded as PNG.

This step is skipped when the user chooses a solid or gradient background, because the background intentionally fills the entire canvas.

The same rasterization function is used by both preview and download. Sharing that path prevents the common bug where the editor looks correct but the saved file has different spacing or clipping.

Small details that mattered

A few less obvious issues were worth handling explicitly.

SVG whitespace

Regular spaces can be collapsed or stripped when SVG text is rasterized. Replacing user-entered spaces with non-breaking spaces keeps single and repeated spaces visible.

Stale asynchronous previews

Rendering is asynchronous. If a user types quickly, an older render can finish after a newer one. Each effect cleanup marks its render inactive so stale results cannot replace the latest preview.

Object URL cleanup

Every generated preview creates an object URL. The previous URL is revoked when a new preview replaces it, and the current URL is revoked when the component unmounts.

Input limits

The editor accepts short display text rather than paragraphs. A small character limit keeps the interface focused and prevents extreme canvas dimensions.

The result

The finished tool is GummyType. It creates bubble, Y2K, and chrome-style word graphics, and everything described above runs locally in the browser.

The project reminded me that a small visual tool can still contain several interesting browser-engineering problems: font portability, SVG composition, accurate measurement, asynchronous rendering, raster export, and memory cleanup.

What I would add next

The current version deliberately keeps the workflow simple. Possible future improvements include:

  • More font shapes without making the control panel overwhelming
  • SVG export for users who need editable artwork
  • Reusable presets for common visual styles
  • Better keyboard controls and accessibility feedback
  • More export backgrounds and layout templates

For now, the most useful constraint has been keeping the tool focused: short text in, finished image out, with no server rendering step in between.

If you have built a browser-based image or SVG tool, I would be interested to hear which rendering edge cases caused the most trouble for you.

Leave a Reply