The Font Subset Trap: Why My ASCII Art Broke on Mobile
I built a terminal-themed personal site with an ASCII art logo. It looked perfect on desktop. On iPhone, the lines didn't line up.
This is the story of three wrong turns and one root cause that I didn't see coming.
The Setup
The logo uses Unicode box-drawing characters — the kind you see in terminal UIs:
██╗ ██╗ ██╗ ██████╗ █████╗ ███████╗
██║ ██║ ██║ ██╔════╝ ██╔══██╗ ██╔════╝
██║ ██║ ██║ ██║ ███████║ ███████╗
██║ ██║ ██║ ██║ ██╔══██║ ╚════██║
███████╗ ╚██████╔╝ ╚██████╗ ██║ ██║ ███████║
╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚══════╝
Two types of characters here:
- Block elements (U+2580-U+259F): █ ╗ ╝ etc.
- Box-drawing characters (U+2500-U+257F): ║ ═ ╔ ╚ etc.
On desktop, everything aligned. On iPhone — subtle but visible misalignment. The double-line characters (║ ═) were slightly different widths than the block elements (█).
Wrong Turn 1: Make It Smaller
First instinct: it's a responsive sizing issue. Tried fontSize: 'min(2vw, 0.75rem)' to scale the ASCII art down on small screens.
Still misaligned. Same proportional error, just smaller.
Wrong Turn 2: Remove the Problem Characters
Replaced all box-drawing characters with solid blocks. Only used █ and spaces.
It worked! But it looked terrible. The original had visual depth from the line-drawing characters. The block-only version was a flat blob. User feedback: "No, sorry, I don't like the trade-off. Can we revert?"
Reverted immediately.
Wrong Turn 3: Zoom to Fit
Rendered the ASCII art at a fixed 12px font size and used CSS zoom with a ResizeObserver to scale it down proportionally on small screens:
useEffect(() => {
const container = logoContainerRef.current;
const pre = container.querySelector("pre");
logoNaturalWidth.current = pre.scrollWidth;
function updateZoom() {
const available = container.clientWidth;
const natural = logoNaturalWidth.current;
setLogoZoom(Math.min(1, available / natural));
}
updateZoom();
const observer = new ResizeObserver(updateZoom);
observer.observe(container);
return () => observer.disconnect();
}, []);
The zoom worked great — the logo scaled beautifully. But the misalignment was still there. The characters were still different widths.
This is when I took a screenshot and actually looked at what was happening.
The Root Cause
The font was JetBrains Mono, loaded via next/font/google:
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-mono",
});
subsets: ["latin"] is the key. Google Fonts splits fonts into Unicode range subsets and only preloads the one you ask for. The Latin subset covers A-Z, 0-9, basic punctuation — but not box-drawing characters (U+2500-U+257F) or block elements (U+2580-U+259F).
When the browser encounters a box-drawing character, it sees it's not in the preloaded font subset. It requests the additional subset file from Google Fonts. But on mobile, before that file loads, the browser falls back to the system font — SF Mono on iOS, which has different character widths for these special Unicode ranges.
Even after the correct font loads, the initial layout with wrong widths can persist in some rendering paths. And if you're on a slow connection, you see the system font for those characters the entire time.
The core issue: in a monospace font, every character must be the same width. But when two different monospace fonts render different characters in the same line, their "mono" widths don't match.
The Fix
Self-host the full font. Download the complete JetBrains Mono woff2 files (1,363 glyphs, ~90KB per weight) and use next/font/local:
import localFont from "next/font/local";
const jetbrainsMono = localFont({
src: [
{ path: "../../fonts/JetBrainsMono-Regular.woff2", weight: "400" },
{ path: "../../fonts/JetBrainsMono-SemiBold.woff2", weight: "600" },
{ path: "../../fonts/JetBrainsMono-Bold.woff2", weight: "700" },
],
variable: "--font-mono",
display: "swap",
});
No subset splitting. One file, all glyphs, consistent widths. The ASCII art aligned perfectly on every device.
The Lesson
Google Fonts' subset splitting is a smart optimization — it reduces initial page load by only sending the characters you need. For 99% of sites, this is the right trade-off.
But if you're using characters outside the Latin range — box-drawing, mathematical symbols, CJK, emoji — the subset boundary becomes a rendering boundary. Different characters might come from different font files, loaded at different times, with potential fallback to system fonts that have different metrics.
If character-width consistency matters, self-host the full font. The size difference is small (90KB vs ~30KB for Latin-only), and you get guaranteed rendering consistency across all Unicode ranges.
The zoom-to-fit technique from Wrong Turn 3? I kept it. It's a great solution for making ASCII art responsive. It just doesn't fix a font problem.
Bonus: The Other Alignment Bug
After fixing the font, I added more ASCII art logos for project pages — "REDBOOK CLI", wider than the original "LUCAS GU". The K letter was visibly broken: the top bar shifted one column left of the rest of the letter. On both desktop and mobile.
This time the font was fine. The root cause was simpler and more embarrassing: inconsistent character counts across lines.
The ASCII art had 6 rows. Lines 1, 4, 5 were 66 characters. Lines 0, 2, 3 were 65. One space was missing in line 0's inter-letter gap, between the B and the first O. That shifted everything to the right of that gap — both O's and the K — one column to the left. Only on the top row.
# Line 0 (broken — 65 chars, K top shifted left)
██████╗ ███████╗ ██████╗ ██████╗ ██████╗ ██████╗ ██╗ ██╗
^^^
3 spaces (should be 4)
# Line 1 (correct — 66 chars)
██╔══██╗ ██╔════╝ ██╔══██╗ ██╔══██╗ ██╔═══██╗ ██╔═══██╗ ██║ ██╔╝
The fix was one space. The debugging was verifying all lines had equal character counts, then diffing against the correct figlet output (npx figlet -f "ANSI Shadow" "REDBOOK") to find where the count diverged.
Lesson: When ASCII art looks misaligned but the font is truly monospace, check character counts first. Run a quick length check on every line — if they're not all equal, that's your bug. A single missing space in the middle of a line shifts everything after it, and the misalignment is most visible at the far right edge.