You spent two hours getting that outlined heading to look perfect in Chrome. It’s bold, it pops, it’s exactly what the design called for. Then you open Firefox, or hand it off to a client on an older Android device, and the text looks like it got chewed up. The outline bleeds inward, the letterforms look pinched, and suddenly your clean typography looks broken. You Google the fix, land on a Stack Overflow thread from 2019, and now you’re more confused than when you started.
I’m Rohan Ratnayake, and I’ve spent the last 5 years as a front-end UI engineer specializing in cross-browser typography rendering, and I’ve watched this exact scenario kill launch timelines on projects that had no business being delayed. The issue isn’t that you don’t know CSS. The issue is that the most commonly recommended solution for text outlines — -webkit-text-stroke — has a fundamental rendering flaw that nobody in tutorial-land bothers to mention until it’s too late. I learned this the hard way on a campaign landing page for a retail client where the outlined logo text looked crisp on my MacBook and completely mangled on the QA team’s Windows machine. We caught it two days before go-live. Not a fun conversation.
This article gives you the actual working method: stacking text-shadow declarations. It’s not glamorous, it’s not new, but it renders consistently across every browser that matters, it doesn’t eat into your letterforms, and once you understand the pattern, you’ll never reach for -webkit-text-stroke again.
Why -webkit-text-stroke Is a Trap (And What It’s Actually Doing)

The problem with -webkit-text-stroke isn’t that it doesn’t work. It works fine on modern WebKit and Blink browsers. The problem is how it draws the stroke.
It centers the stroke on the edge of each letterform. That means if you set a 4px stroke, 2px goes outside the letter and 2px goes inside. On thin characters — lowercase “i”, “l”, the counter of an “a” — that inward stroke physically eats the negative space inside the letter. At any size above about 2px, your text starts to look bold and muddy rather than outlined.
Here’s a quick comparison of what you’re dealing with:
| Property | Browser Support | Stroke Direction | Affects Letterform Interior | Standard? |
|---|---|---|---|---|
-webkit-text-stroke | Modern Chrome, Firefox, Safari | Centered (inward + outward) | Yes — visibly distorts thin letters | No (vendor prefix) |
text-shadow stacking | Every browser including IE9+ | Outward only | No | Yes |
paint-order + SVG stroke | SVG text only | Configurable | No | SVG spec only |
CSS filter: drop-shadow | Modern browsers | Outward, blurry | No | Yes |
The paint-order trick is genuinely useful but it’s locked to SVG text, which rules it out for standard HTML. filter: drop-shadow gives you a soft halo, not a crisp outline. That leaves text-shadow stacking as the only clean, standard, cross-browser solution for HTML text.
The text-shadow Stacking Method, Explained

text-shadow accepts multiple comma-separated values. Each value is: x-offset y-offset blur-radius color. The trick is to set blur-radius to 0 and push the shadow in all four diagonal directions — and for heavier outlines, you fill in the cardinal directions too.
Here’s the base pattern for a 1px outline:
.outlined-text {
text-shadow:
1px 1px 0 #000,
-1px 1px 0 #000,
1px -1px 0 #000,
-1px -1px 0 #000;
}
This covers all four diagonal corners. For most light outline use cases, it’s enough. But at 1px on certain screen densities, you’ll notice tiny gaps at the cardinal points (top, bottom, left, right) of each curve. To fill those in, extend to 8 values:
.outlined-text {
text-shadow:
1px 0 0 #000,
-1px 0 0 #000,
0 1px 0 #000,
0 -1px 0 #000,
1px 1px 0 #000,
-1px 1px 0 #000,
1px -1px 0 #000,
-1px -1px 0 #000;
}
This is the version I use by default. It’s clean, it renders without gaps, and it adds zero visual weight to the interior of the letterforms.
Scaling Up: 2px and 3px Outlines Without Gaps
Going past 1px is where people get sloppy. I’ve seen tutorials that just scale the offset values up to 2px and call it done. That leaves visible gaps in the corners of rounded letterforms — it looks like a dotted outline on thick strokes, which is worse than no outline at all.
For a 2px outline, you need to cover every integer coordinate within a 2px radius. That means adding values at 1px, 2px, and diagonal combinations:
.outlined-text-2px {
text-shadow:
2px 0 0 #000,
-2px 0 0 #000,
0 2px 0 #000,
0 -2px 0 #000,
2px 2px 0 #000,
-2px 2px 0 #000,
2px -2px 0 #000,
-2px -2px 0 #000,
1px 2px 0 #000,
-1px 2px 0 #000,
1px -2px 0 #000,
-1px -2px 0 #000,
2px 1px 0 #000,
-2px 1px 0 #000,
2px -1px 0 #000,
-2px -1px 0 #000;
}
It looks like a lot of lines. It is. But the CSS payload is tiny — this entire block compresses to under 500 bytes. Performance is not a concern here.
For 3px, the same logic applies: you need to fill the entire integer grid within a 3px radius. At that point, a SCSS mixin or a PostCSS plugin is worth considering so you’re not maintaining that block by hand.
A Practical SCSS Mixin You Can Drop In Today
If your project uses SCSS, you can generate the full shadow stack programmatically. Here’s a mixin I’ve been using for about three years:
@mixin text-outline($size, $color) {
$shadows: ();
@for $x from #{-$size} through $size {
@for $y from #{-$size} through $size {
@if not ($x == 0 and $y == 0) {
$shadows: append($shadows, #{$x}px #{$y}px 0 $color, comma);
}
}
}
text-shadow: $shadows;
}
// Usage:
h1 {
@include text-outline(2, #1a1a1a);
}
This generates every coordinate in the grid, skips 0 0 (which would be no offset, pointless), and outputs the full comma-separated text-shadow chain. You get a mathematically complete outline with one line of usage code.
One thing to keep in mind: this mixin generates (2n+1)² - 1 shadow values. For $size: 1 that’s 8. For $size: 3 that’s 48. For $size: 4 it jumps to 80. At sizes above 3px, you’re better off combining this with a slight blur-radius of 0.5px rather than trying to fill a 4px grid with pure solid shadows — the visual result is nearly identical and the property list stays manageable.
When the Outline Color Needs to Be Semi-Transparent
This is where a lot of developers hit an unexpected bug. If you stack 16 shadow values all with the same semi-transparent color — say rgba(0, 0, 0, 0.5) — the overlapping shadows compound. Where the diagonal and cardinal values overlap at the same pixel, you get a shadow with double the opacity. The outline ends up uneven, darker in corners than along flat edges.
The fix is to use a fully opaque color and control transparency at the element level:
.outlined-text {
color: transparent;
text-shadow: /* your 8 or 16-value stack with a solid color */;
opacity: 0.7; /* control overall transparency here */
}
Or if you can’t touch opacity (because it affects child elements), convert your target color to its fully-opaque equivalent and accept that this particular effect doesn’t mix cleanly with RGBA shadow stacking. The compounding behavior is not a bug you can patch — it’s just how layered alpha compositing works.
Hollow Text (Transparent Fill with an Outline)
One of the most common use cases for text outlines is hollow or “knockout” text — where the fill is transparent and only the stroke is visible. With -webkit-text-stroke, this is easy: set color: transparent and add the stroke. With text-shadow, it’s slightly different because text-shadow renders behind the text fill.
To get a true hollow effect:
.hollow-text {
color: transparent;
-webkit-text-stroke: 1px #000; /* keep as enhancement only */
text-shadow:
1px 1px 0 #000,
-1px 1px 0 #000,
1px -1px 0 #000,
-1px -1px 0 #000,
1px 0 0 #000,
-1px 0 0 #000,
0 1px 0 #000,
0 -1px 0 #000;
}
Here’s the honest trade-off: text-shadow alone can’t produce a perfectly hollow letter because the shadow sits behind a transparent fill, which means the interior of curves (the counter of an “O”, the bowl of a “P”) shows the shadow through. For thin outlines at 1px, this is barely visible. For thicker outlines, SVG text with paint-order: stroke fill is genuinely the right tool — but only if you control the markup.
| Goal | Best Tool | Notes |
|---|---|---|
| 1–2px outline, HTML text | text-shadow stack | Cleanest cross-browser option |
| 3px+ outline, HTML text | text-shadow + SCSS mixin | Shadow count grows; still solid |
| Hollow text, design system | SVG <text> + paint-order | Full control, requires SVG markup |
| Soft glow/halo effect | filter: drop-shadow | Not a crisp outline |
| Quick prototype only | -webkit-text-stroke | Fine for personal projects, never production |
Three Things That Will Still Trip You Up

Even with the text-shadow method locked in, there are a few edge cases worth knowing before you ship:
- Dark backgrounds with light outlines:
text-shadowdoesn’t automatically inherit contrast from the background. If your text is white on a white-ish background, a white shadow is invisible. Always test on actual background colors, not just the dev tools isolation view. - Printing:
text-shadowis often stripped by print stylesheets or suppressed by browsers in print mode. If your outlined text needs to survive a print layout, add a@media printblock that falls back to a border or a different treatment. - Very small text sizes: Below about
14px, even a1pxtext-shadowoutline starts to make text harder to read, not easier. The shadow fills in tight letter spacing and reduces legibility. According to WCAG accessibility guidelines maintained by the W3C, decorative text treatments that reduce readability can directly impact compliance — worth knowing if your project has any accessibility requirements.
For a reliable look at current browser compatibility across the text-shadow property spectrum, MDN’s text-shadow reference is the most accurate and regularly maintained source available.
FAQs
Can I use both -webkit-text-stroke and text-shadow together? Yes, and for modern browsers only, it’s actually a useful combination. Use -webkit-text-stroke as a progressive enhancement and text-shadow as the fallback baseline. Browsers that support -webkit-text-stroke will render both, but since -webkit-text-stroke renders on top, it visually wins. Just know that on older browsers, only the text-shadow version renders, which is exactly what you want.
Why does my text-shadow outline look thicker in some places than others? You’re most likely missing the cardinal direction shadows (top, bottom, left, right) and only using the four diagonal values. The corners of curves get more coverage than the flat segments, which makes the outline look uneven. Add all 8 values as shown in the 8-point pattern above, and the distribution evens out.
Does text-shadow stacking affect text selection or accessibility? No. text-shadow is purely visual. It doesn’t affect the DOM, doesn’t interfere with screen readers, and doesn’t change how text selection works. It’s one of the cleanest ways to add visual styling without any accessibility side effects.
Wrapping Up
The real problem with -webkit-text-stroke isn’t that it exists — it’s that it gets recommended without the disclaimer that it carves into your letterforms and has no standard fallback. The text-shadow stacking method is more verbose, yes, but it renders predictably, works everywhere from IE9 to the latest Chrome, and doesn’t distort your type.
Start with the 8-value pattern for 1px outlines. Move to the full grid for 2px. Use the SCSS mixin if you’re repeating it across a design system. And if you need true hollow text with thick strokes, switch to SVG — that’s not a workaround, that’s just the right tool for that specific job.
The next step is practical: open your current project, find anywhere you’ve used -webkit-text-stroke, and swap in the 8-value text-shadow stack. Test it on Firefox and a mid-range Android browser. That check alone will save you the fire drill I had two days before launch.

