Frontend
-
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].
-
CSS Finally Got Inline Conditionals
I’ve been digging into some of the newer features landing in CSS, and inline conditionals with the
if()function jumped out as one of the more interesting ones. The idea of writing a condition right next to the property it affects, instead of opening a separate@mediablock ten lines away, sounds useful. So this post is my attempt at summarizing what I found. I hope it helps if you’re trying to figure out whatif()actually does and where it fits.What It Actually Does
The shape is condition, colon, value, with an optional
elsebranch:property: if(<condition>: <value>; else: <fallback>;);You evaluate something, you get back a value, and the property uses that value. The
else:branch is optional. If you leave it off and the condition fails, the property falls back to its inherited or initial value.That’s it.
What makes it powerful is what you can put in that condition slot. Three kinds of queries are supported:
media()for viewport and device conditions,style()for custom property values on the element, andsupports()for feature detection. The same things you’ve always been able to ask CSS, but now you can ask them inline, right next to the property they affect.Theming Without the Yo-Yo
Dark mode is the most obvious win.
.card { background-color: white; color: black; } @media (prefers-color-scheme: dark) { .card { background-color: #333; color: white; } }And here’s the same thing with
if():.card { background-color: if(media(prefers-color-scheme: dark): #333; else: white;); color: if(media(prefers-color-scheme: dark): white; else: black;); }The logic lives next to the property. You don’t have to hunt down a separate block to figure out what happens in dark mode. When you change the light color, the dark color is right there staring at you. That’s a real maintenance win, especially in a stylesheet that’s been touched by five different people.
Variants Without Modifier Classes
This is the example that makes the case best. Component libraries are drowning in modifier classes.
.btn-primary,.btn-danger,.btn-success, on and on. Every one of them is just a different background color and maybe a border.With
if()and a custom property, you can collapse the whole thing:.button { padding: 10px 20px; border-radius: 6px; color: white; background-color: if( style(--variant: danger): red; style(--variant: success): green; else: blue; ); }<button class="button">Submit</button> <button class="button" style="--variant: danger;">Delete</button>No JavaScript reading props and toggling class names. No BEM modifier soup. You set a variable, the CSS reacts. The component owns its own logic.
Feature Detection Inline
The same pattern works for graceful degradation when you want a newer feature with a fallback:
.element { display: if(supports(display: grid): grid; else: flex;); color: if( supports(color: lch(75% 0 0)): lch(75% 0 0); else: rgb(185 185 185); ); }This used to require a dedicated
@supportsblock with the property repeated inside. Now it doesn’t.Observations
A few things are happening here that go deeper than syntactic sugar.
The first is bloat reduction. A component that used to need a base class plus four modifier classes plus a media query block can be one selector. Multiply that across a design system and the savings get real.
The second is encapsulation. A component’s stylesheet can carry its own logic without relying on a JavaScript framework to compute the right class name and shove it into the DOM. The CSS engine is doing the work, natively, and it’s a lot faster at this than your render loop is.
If the logic lives where the value lives, you don’t context-switch to figure out why a property has the value it does. You read the property, you read the condition, you understand it, you move on.
That’s the same reason inline error handling beats a separate try/catch block ten lines away. Co-location is a maintenance superpower.
Browser Support
Browser support is still narrow. Chrome shipped
if()in version 137, but Firefox and Safari haven’t followed yet, so this isn’t something to drop into a production stylesheet without a fallback. The pattern is to declare a plain value first and then override withif(), so non-supporting browsers ignore the line they don’t understand:.card { background-color: white; background-color: if(media(prefers-color-scheme: dark): #333; else: white;); }The syntax has also shifted through CSS Working Group drafts, so older articles you find about
if()may show a comma-ternary form that no longer works. Check MDN before copying anything.But the direction is right. CSS has been quietly absorbing more and more of the work we used to push to JavaScript, and
if()is one of the bigger steps in that direction. I’ll probably keep poking at it.Sources
- if() CSS function — MDN
- CSS conditionals with the new if() function — Chrome for Developers
- Lightly Poking at the CSS if() Function in Chrome 137 — CSS-Tricks
- CSS if() function — Can I use
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].