Bubble text looks like a font effect, but a copy-and-paste generator does not render a font at all. It replaces ordinary Latin letters with existing Unicode characters such as Ⓑ, ⓑ, 🅑, 🄱, and ⒝.
That distinction changes almost every implementation decision. The result must remain real text, unsupported characters need a safe fallback, and each operating system is free to draw the same code point differently.
I recently built a small browser-only generator around those constraints. Here is how the conversion works, where Unicode makes the job easy, and where the character set becomes uneven.
Bubble text is character substitution
A CSS font family can change how text looks on one page, but copying that text normally gives the original letters. A Unicode bubble generator changes the characters themselves:
Bubble 123
Ⓑⓤⓑⓑⓛⓔ ①②③
Because the output is text, it can be selected, copied, searched, and pasted into compatible fields. There is no canvas, SVG, image upload, or font file involved.
Map code points instead of storing giant lookup objects
Several useful Unicode ranges are contiguous. Circled capital A begins at U+24B6, while the ASCII capital A begins at U+0041. The difference between those values can be used as an offset for the whole A–Z range.
const CIRCLED_UPPER = 0x24b6 - 0x41
const CIRCLED_LOWER = 0x24d0 - 0x61
function isUpper(cp: number) {
return cp >= 0x41 && cp <= 0x5a
}
function toCircled(cp: number, original: string) {
if (isUpper(cp)) {
return String.fromCodePoint(cp + CIRCLED_UPPER)
}
if (cp >= 0x61 && cp <= 0x7a) {
return String.fromCodePoint(cp + CIRCLED_LOWER)
}
return original
}
This is smaller and easier to audit than maintaining a large object containing every letter. It also makes the boundaries explicit: only characters inside the supported ranges are transformed.
Iterate by code point, not UTF-16 code unit
JavaScript strings use UTF-16 internally. Indexing with a classic numeric loop can split characters outside the Basic Multilingual Plane into surrogate halves.
A for...of loop iterates by Unicode code point, which makes it a safer default:
function convertWith(text: string, map: CharMap) {
let output = ""
for (const character of text) {
const codePoint = character.codePointAt(0)
output += codePoint === undefined
? character
: map(codePoint, character)
}
return output
}
Even when the generator only transforms A–Z, users will paste emoji, punctuation, accented letters, and characters from other scripts. Iterating safely prevents those inputs from being damaged.
Unicode style ranges are not symmetrical
The tricky part is that each visual family supports a different set of characters.
- Circled letters include uppercase and lowercase Latin letters.
- Negative circled letters are uppercase, so lowercase input must be folded to uppercase if that style is selected.
- Squared letters are also uppercase-only.
- Parenthesized letters use lowercase forms.
- Circled and dark circled digits have different ranges.
- Parenthesized digits cover 1–9, while zero has no matching character in that set.
- Squared digits are not available as a matching sequence, so the original digit should pass through.
That means a style cannot be represented only by a single offset. Each style needs a small mapping policy for case folding, letters, digits, and fallback behavior.
Preserve characters you cannot convert
A generator should never silently delete or guess unsupported input. If a user types café, a squared-uppercase style can convert the supported letters and preserve the rest:
café → 🄲🄰🄵é
The same rule keeps spaces, punctuation, emoji, and non-Latin scripts intact. Partial conversion is predictable; destructive conversion is not.
Show every result at once
I originally considered a style dropdown, but the output is easier to choose when all variants are visible together. For each keystroke, the interface runs the same input through six mapping functions and renders six rows:
const rows = styles.map((style) => ({
id: style.id,
label: style.label,
output: style.convert(input),
}))
The data set is tiny, so there is no reason to add a server request or generation queue. The whole interaction can remain local to the browser.
Each row gets its own Copy button. navigator.clipboard.writeText() is enough for modern secure contexts, but the call should still be wrapped in try/catch because clipboard permission or browser policy can reject it.
Compatibility is part of the product
Unicode defines characters, not an identical visual appearance everywhere. A circled letter may have different spacing, weight, or proportions on macOS, Windows, Android, and individual apps. Some username fields also reject unusual Unicode even when normal messages accept it.
A responsible UI should say this clearly:
- The result is Unicode text, not a downloadable font.
- Appearance can vary by device and app.
- Some fields may reject the characters.
- Important information should remain in ordinary text.
- Users should test the result in its final destination.
This is more useful than promising that decorative text works everywhere.
The result
I used these rules in GummyType's Bubble Text Generator. It produces six copy-and-paste styles, preserves unsupported characters, and keeps conversion inside the browser.
The implementation is small, but it is a useful example of a broader rule: when a tool transforms text, the edge cases are part of the feature. Unicode ranges, case behavior, digits, fallback policy, clipboard errors, and platform rendering all deserve explicit decisions.
Takeaways
- Bubble text generators substitute Unicode characters; they do not apply fonts.
- Contiguous Unicode ranges make offset-based mapping practical.
- Each style still needs its own case and digit policy.
- Iterate by code point and preserve unsupported input.
- Generate locally and show variants together when the data set is small.
- Treat compatibility limitations as product information, not footnotes.
Small text tools become much more reliable when their boundaries are designed as carefully as their happy path.
Top comments (0)