Svg
-
Building Speech and Thought Bubbles in Svelte
Have you ever looked at a comic-style speech bubble and thought “that’s just a rounded rectangle with a little triangle”? That’s what I thought too. Then I tried to build one that stays intentional at every size, and I was wrong.
Bubble Lab started as a small Svelte 5 playground for a deceptively specific UI problem: comic-style speech bubbles and thought bubbles you can copy into another project without dragging along a whole positioning system. Pretty good progress so far! It does bubbles, tails, and puffs in smooth or pixel-art style, plus a live editor to tune them and copy the snippet out.
Unfortunately, the code is not public. So let me know if that interests you.
The bubble body is easy but I struggled with the tails and puffs. This is not a full code walkthrough, but rather my approach at building it. The real implementation has props, presets, app state and tests.
Keep the Bubble Presentational
The presentation boundary is important.
SpeechBubbleowns the body frame, the fill and ink colors, the text padding, the tail shape, and smooth vs. pixel rendering.ThoughtBubbleowns the body frame, the puff trail, puff direction and count, and smooth vs. pixel rendering.Positioning is somebody else’s job. If a bubble needs to float next to a heading, point at a chart, or become an annotation, that belongs to a wrapper like
AnchoredAnnotation, not the bubble. That keeps the API small:<SpeechBubble tail="right" color="cyan" pixel> Copy this into your own scene. </SpeechBubble> <ThoughtBubble puffs={3} puffDirection="down-left" color="cream"> How should this trail land? </ThoughtBubble>SVG for the Frame, HTML for the Text
The body text stays real HTML. It wraps naturally, holds inline markup, stays selectable, and keeps accessibility boring in the best way. The frame is an absolutely positioned SVG behind the content:
<svg class="bubble-frame" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true"> <path d={d} fill="var(--bubble-fill)" stroke="var(--bubble-ink)" vector-effect="non-scaling-stroke" shape-rendering={pixel ? "crispEdges" : "geometricPrecision"} /> </svg> <div class="bubble-content">{@render children?.()}</div>preserveAspectRatio="none"earns its keep because the frame stretches to whatever the content needs. The path lives in a normalized 100x100 viewBox, and CSS controls the real size. Smooth mode uses quadratic curves at the corners. Pixel mode uses straight segments andcrispEdges. One clean switch.The Tail Should Not Live in the Body Path
I made this mistake so you don’t have to. The original speech tail lived inside the same stretched 100x100 body SVG.
The body frame stretches differently depending on the content. A left or right tail flattens as the bubble gets wider. An up or down tail collapses into a sad little nub. The tail is supposed to stay triangular, but it was living in a coordinate system whose entire job was to deform.
The fix was to split the tail into its own fixed-aspect 16x16 SVG. The body still stretches. The tail does not. A triangle stays a triangle at every bubble width. The tail path is intentionally open, so the fill path closes with
Zbut the stroked path doesn’t. That keeps an ink line from drawing across the tail base where it overlaps the body.One tiny detail:
stroke-linecap="round"left little nubs at the tail base corners. Switching the open stroke tobuttmade the overlap read as one continuous outline. That’s the fix.Pixel Tails Need More Vertices, Not a New Component
The pixel version doesn’t need a second component. It needs a different path generator. The smooth tail is
base corner -> tip -> base corner. The pixel tail isbase corner -> stair-step to tip -> stair-step to base corner. Same API, difference pushed intotailPath(tail, pixel).And it’s easy to test without booting a browser:
expect(tailPath("left", false)).not.toMatch(/Z\s*$/); expect(tailPath("left", true)).not.toMatch(/[QqCcSsTtAa]/); expect(pixelVertexCount).toBeGreaterThan(smoothVertexCount);Those tests don’t prove the bubble is pretty. They protect the geometry contract: open stroke, no curves in pixel mode, a real stepped path.
Pixel puffs were trickier. A clipped octagon with a normal border didn’t give a clean stepped outline, and on pale fills the smallest puffs read as solid ink dots. The fix was an ink-colored clipped backing with a smaller fill-colored clipped pseudo-element inset on top, where the inset scales with size but has a floor and a cap. It’s not perfect.
And padding counts as geometry too. The body path is inset inside the SVG, so if the text padding is too tight the copy crowds the frame stroke, especially on wide bubbles. The frame can be mathematically correct and still look wrong if the text doesn’t get room to breathe inside it.
What Still Needs Work
Bubble Lab is a good first attempt, not a finished bubble engine. The next round is more visual than architectural: more tail geometries including curved comic tails, better per-direction placement, more natural puff arcs, finer pixel tuning at tiny sizes, and browser screenshots as regression tests for clipping and legibility.
Svelte is a great shell for the API and the editor, but the quality comes from the small rendering choices. Not the exact code, the split: stretch the body, isolate the tail, keep text as HTML, generate paths with TypeScript, test the geometry, and use a playground to test taste. Do that and you get a component that’s simple to use but honest about the details that make a comic bubble feel drawn instead of assembled.
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].