Letterpress printers dealt with ink spread for centuries. When a metal or wood block is pressed into paper, the ink doesn’t stay precisely within the letterform. It bleeds outward. Compositors called it ink gain and compensated by cutting type slightly lighter than the intended final weight. The difference between the cut and the print was the stroke.
CSS stroke-width doesn’t know about this. It splits the stroke centered on the edge, half inside, half outside. You cannot move it. For a true outer stroke on text you need feMorphology operator="dilate", and once I understood that, I started seeing the whole filter-on-type space differently. Most of these effects aren’t browser tricks. They’re things printers solved mechanically, first.
The destination: a gilded initial
Medieval scribes could spend days on a single initial, laying gold leaf and burnishing it until the letter caught the light off the page. That instinct, treating the first letter as an object worth decorating, is where this article is heading. So before the primitives, here is what they add up to.
Every initial here is gilded by one stack of filters: a crimson keyline from feMorphology, a gold gradient for the body, and a feSpecularLighting relief that catches the cursor. Pick any letter and the paragraph still wraps to its shape, because shape-outside reads the glyph itself, not the box around it. The fine engraving in an old specimen book was always an illustrator’s job; the gilding was not.
<svg viewBox="0 0 240 220">
<defs>
<linearGradient id="gold" x1="0" y1="0" x2="0.55" y2="1">
<stop offset="0" stop-color="#fdeeb0" /><stop offset=".42" stop-color="#d4a23a" />
<stop offset=".58" stop-color="#f6d77a" /><stop offset="1" stop-color="#a9761a" />
</linearGradient>
<filter id="gild" color-interpolation-filters="sRGB">
<feMorphology operator="dilate" radius="3" in="SourceAlpha" result="k" />
<feFlood flood-color="#5b1410" result="kc" />
<feComposite in="kc" in2="k" operator="in" result="key" />
<feGaussianBlur in="SourceAlpha" stdDeviation="1.5" result="b" />
<feSpecularLighting surfaceScale="3.5" specularConstant="0.9"
specularExponent="22" lighting-color="#fff6df" in="b" result="s">
<fePointLight x="90" y="60" z="80" />
</feSpecularLighting>
<feComposite in="s" in2="SourceAlpha" operator="in" result="shine" />
<feMerge>
<feMergeNode in="key" /><feMergeNode in="SourceGraphic" />
<feMergeNode in="shine" />
</feMerge>
</filter>
</defs>
<text x="120" y="182" text-anchor="middle" font-size="190"
fill="url(#gold)" filter="url(#gild)">S</text>
</svg>
// re-wrap the paragraph whenever the letter changes
function setLetter(ch) {
letter.textContent = ch;
const shape = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 240 220'>"
+ "<text x='120' y='182' text-anchor='middle' font-family='Georgia,serif'"
+ " font-weight='700' font-size='190'>" + ch + "</text></svg>";
svg.style.shapeOutside = 'url("data:image/svg+xml,' + encodeURIComponent(shape) + '")';
}
No panel, no frame, just the letter and the gold. None of it knows it was ever a letter: the SVG filter spec was built for images, but everything runs against pixels regardless of source, and text is just pixels after rasterization. That is the whole trick, and the rest of this article is the vocabulary.
The deepest treatment of it is still a 2015 Smashing Magazine piece by Dirk Weber, “The Art Of SVG Filters And Why It Is Awesome”. Its feConvolveMatrix section is the clearest explanation of kernel matrices I’ve found.
The primitives that matter for type
Most filter primitives work on any element. A few are especially useful for typography:
| Primitive | What it does for type |
|---|---|
feMorphology dilate | True outer stroke, expands the silhouette outward |
feMorphology erode | Thins letterforms, useful for optical weight adjustment |
feConvolveMatrix | Extrusion, bevel, emboss from a kernel matrix |
feSpecularLighting | Simulated 3D surface using the alpha channel as a height map |
feDisplacementMap | Distorts letterforms organically via a noise field |
feColorMatrix | Full control over color, alpha, desaturation, channel routing |
The key input is SourceAlpha, the opaque region of the element filled solid black. Most type filter effects start from there rather than from SourceGraphic.
A true outer stroke (feMorphology)
CSS text-stroke is centered, half the stroke bleeds inward and eats into the letterform. feMorphology operator="dilate" expands the silhouette outward by exactly radius pixels. Stack a feFlood color through feComposite operator="in", merge it under SourceGraphic, and you have a true outer stroke, the whole shape pushed outward instead of straddling the edge.
One honest caveat: feMorphology dilates with a square kernel, so corners square off and the stroke reads chunky as the radius climbs (watch what happens at radius="4" below). For a heavy slab edge that is exactly what you want; for a smooth rounded stroke it is the wrong tool, and a duplicated <use> with stroke-linejoin="round" will look better.
FOUNDRY
<!-- dilate: outer stroke -->
<filter id="outer-stroke">
<feMorphology operator="dilate" radius="2" in="SourceAlpha" result="thick" />
<feFlood flood-color="#884AFF" result="ink" />
<feComposite in="ink" in2="thick" operator="in" result="colored" />
<feMerge>
<feMergeNode in="colored" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<!-- erode: thin the letterforms -->
<filter id="thin-text">
<feMorphology operator="erode" radius="1" in="SourceGraphic" />
</filter>
erode works in the opposite direction: thins the letterform. Set radius="1" and you shave one pixel off every edge. At radius="2" on a thin typeface you can start losing strokes entirely. It reads as a lighter optical weight without changing the font or CSS.
Shadow woodtype (stacked feOffset)
This is what 19th century shadow woodtype was: two blocks, same letterform, the second one offset and printed in a darker color. The shadow was a separate physical object.
The textbook tool for rebuilding it is feConvolveMatrix, a pixel kernel that recomputes each pixel from its neighbors. Put 1s down a diagonal and every pixel smears one step further that way, stacking into depth. A 5×5 diagonal kernel gives a 5px trail toward the SE:
1 0 0 0 0
0 1 0 0 0
0 0 1 0 0
0 0 0 1 0
0 0 0 0 1
It works, but the kernel is one fixed matrix. You cannot recolor the trail independently of the face, and you cannot tween a matrix, so the moment the shadow needs to change direction or animate you are fighting it. Stacked feOffset gives the same diagonal trail with a handle on every step: five copies of the dilated alpha, each pushed one notch further, merged into a solid block. feComposite operator="out" then cuts the original letterform back out so only the shadow survives, and an feFlood colors it. That is what the demo animates below.
WOODTYPE
<filter id="extrude" color-interpolation-filters="sRGB">
<feMorphology operator="dilate" radius="2" in="SourceAlpha" result="thick" />
<!-- 5 stacked offsets = graduated extrusion depth -->
<feOffset dx="1" dy="1" in="thick" result="e1" />
<feOffset dx="2" dy="2" in="thick" result="e2" />
<feOffset dx="3" dy="3" in="thick" result="e3" />
<feOffset dx="4" dy="4" in="thick" result="e4" />
<feOffset dx="5" dy="5" in="thick" result="e5" />
<feMerge result="extrusion">
<feMergeNode in="e1" /><feMergeNode in="e2" />
<feMergeNode in="e3" /><feMergeNode in="e4" />
<feMergeNode in="e5" />
</feMerge>
<!-- cut source silhouette out, color, merge under text -->
<feComposite operator="out" in="extrusion" in2="thick" result="extr-only" />
<feFlood flood-color="#E93330" result="color" />
<feComposite in="color" in2="extr-only" operator="in" result="colored" />
<feMerge>
<feMergeNode in="colored" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
Metal under a lamp (feSpecularLighting)
Every type specimen photographer knows this shot. Point light, metal type, the raised letterform catching the light while the counter falls into shadow. feSpecularLighting simulates exactly that: it reads the blurred SourceAlpha as a height map and computes highlights from a virtual light. Move fePointLight’s x, y, z and the highlight tracks across the letterforms.
Three attributes control the look:
surfaceScale: how “tall” the surface bumps read asspecularExponent: higher means tighter, glossier highlightspecularConstant: overall intensity
The output is a grayscale highlight layer. Clip it to SourceAlpha with feComposite operator="in", then feBlend mode="screen" it over the original.
LEAD TYPE
<filter id="specular" color-interpolation-filters="sRGB">
<!-- blurred alpha = height map for the lighting calculation -->
<feGaussianBlur stdDeviation="3" in="SourceAlpha" result="blur" />
<feSpecularLighting
surfaceScale="9"
specularConstant="1.4"
specularExponent="32"
lighting-color="#ffffff"
in="blur"
result="spec"
>
<fePointLight x="220" y="-50" z="160" />
</feSpecularLighting>
<!-- clip highlights to letterform shape -->
<feComposite in="spec" in2="SourceAlpha" operator="in" result="spec-clipped" />
<!-- screen over original: highlights add, darks disappear -->
<feBlend in="SourceGraphic" in2="spec-clipped" mode="screen" />
</filter>
scene.addEventListener('mousemove', e => {
const { left, top } = scene.getBoundingClientRect();
pointLight.setAttribute('x', e.clientX - left);
pointLight.setAttribute('y', e.clientY - top);
});
Worn letterpress (feTurbulence)
Worn letterpress blocks looked like this. Cracked wood, ink buildup, strokes that had thinned from too many runs. The letterform was still recognizable but something had happened to it. feTurbulence gets you there without destroying anything. It’s non-destructive, lives in the filter, and the original text stays intact underneath.
The same feTurbulence + feDisplacementMap stack from image distortion works directly on text. Low scale values give a subtle hand-lettered wobble. High values shred the letterforms completely. The type="fractalNoise" variant gives softer edges than type="turbulence".
The filter region needs expanding because displacement can push pixels outside the element’s original bounding box. Without the expanded region, pixels near the edge disappear.
INK BLEED
<!-- expand filter region: displacement can push pixels outside bounding box -->
<filter id="text-distort" color-interpolation-filters="sRGB"
x="-15%" y="-30%" width="130%" height="160%">
<feTurbulence
type="fractalNoise"
baseFrequency="0.035"
numOctaves="3"
seed="8"
result="noise"
/>
<feDisplacementMap
in="SourceGraphic"
in2="noise"
scale="6"
xChannelSelector="R"
yChannelSelector="G"
/>
</filter>
// lerp toward target scale when preset changes
currentScale += (targetScale - currentScale) * 0.08;
map.setAttribute('scale', currentScale);
The four above are the load-bearing primitives. What follows stacks them into effects I keep in a snippets file and reach for when a layout needs more than the typeface gives me on its own.
Gooey merge (feGaussianBlur + feColorMatrix)
This is ink gain with the brakes off. The opening of this piece was about printers fighting to keep ink inside the letterform; the goo filter is what happens when you let the bleed win. A heavy feGaussianBlur spreads neighboring glyphs into one another, then an feColorMatrix with a steep alpha ramp snaps the soft edges back to one hard, fused shape. Animate the tracking and the letters run together and pull apart again.
The alpha trick used to fall apart in Safari and Firefox, where it degraded to a plain blur. In 2026 it holds, but it is the one effect here that fails ugly rather than gracefully, so test it before you ship it.
MERCURY
<filter id="goo" color-interpolation-filters="sRGB"
x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur in="SourceGraphic" stdDeviation="7" result="blur" />
<!-- steep alpha ramp: soft blur snaps back to hard shapes -->
<feColorMatrix in="blur" type="matrix"
values="1 0 0 0 0
0 1 0 0 0
0 0 1 0 0
0 0 0 19 -9" result="goo" />
<feComposite in="SourceGraphic" in2="goo" operator="atop" />
</filter>
// oscillate tracking so neighbours touch, then part
text.style.letterSpacing = (Math.sin(t) * 0.07 - 0.03) + 'em';
Chromatic split (feColorMatrix + feOffset)
Run a job on a misaligned press and the color plates stop agreeing. The cyan ghosts off the black, every edge grows a fringe. Screens have their own version of the same defect: a misconverged CRT, a cheap lens splitting light at its edges. Same registration error, different machine. Isolate the red, green and blue channels with three feColorMatrix passes, push two of them apart with feOffset, and screen them back together. Where the channels line up you get white; where they don’t, colored fringes. I leave the green channel unmoved so the word stays readable while it shears.
SIGNAL
<filter id="chroma" color-interpolation-filters="sRGB"
x="-15%" y="-15%" width="130%" height="130%">
<!-- keep only red, then shift it left -->
<feColorMatrix in="SourceGraphic"
values="1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0" result="r" />
<feOffset in="r" dx="-4" result="ro" />
<!-- green stays put: the readable anchor -->
<feColorMatrix in="SourceGraphic"
values="0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 1 0" result="g" />
<!-- blue shifts right -->
<feColorMatrix in="SourceGraphic"
values="0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 0" result="b" />
<feOffset in="b" dx="4" result="bo" />
<feBlend in="ro" in2="g" mode="screen" result="rg" />
<feBlend in="rg" in2="bo" mode="screen" />
</filter>
// jitter the offsets so the split shimmers
offsetR.setAttribute('dx', -(base) + Math.sin(t) * 1.5);
offsetB.setAttribute('dx', (base) + Math.sin(t) * 1.5);
Tube-light glow (feFlood + feGaussianBlur)
Here the printing metaphor finally runs out. Neon is bent glass and electrified gas, a bright core with colored light bleeding into the dark around it, nothing a press ever did. Tint SourceAlpha with feFlood through a composite, blur it twice at two radii for a tight inner edge and a wide outer halo, then merge the crisp letters back on top. The double blur is what separates it from a plain drop shadow: a real tube burns hot at the core and soft at the rim.
OPEN
<filter id="neon" color-interpolation-filters="sRGB"
x="-50%" y="-50%" width="200%" height="200%">
<feFlood flood-color="#FF3366" result="color" />
<feComposite in="color" in2="SourceAlpha" operator="in" result="tinted" />
<!-- two radii: hot inner edge + soft outer bloom -->
<feGaussianBlur in="tinted" stdDeviation="2" result="glow1" />
<feGaussianBlur in="tinted" stdDeviation="8" result="glow2" />
<feMerge>
<feMergeNode in="glow2" />
<feMergeNode in="glow1" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
// flicker the outer bloom, with the odd dropout
let glow = 8 + Math.sin(t) * 1.5;
if (Math.random() < 0.015) glow = 2.5;
outerBlur.setAttribute('stdDeviation', glow);
Applying to HTML and CSS, not just SVG
An SVG <filter> is one object. What changes is where you point it.
Inside an SVG, apply it as an attribute on the element you want filtered:
<text filter="url(#ink)">Petrol</text>
On any HTML element, reference the same filter from CSS:
.headline { filter: url(#ink); }
Or skip SVG entirely and reach for CSS’s built-in functions:
.headline { filter: blur(4px) drop-shadow(0 2px 6px #0008); }
Those functions (blur, contrast, drop-shadow, hue-rotate) are shorthands for a few common primitives, and they tween with ordinary CSS transitions. But there is no function for feMorphology, feDisplacementMap or feConvolveMatrix. The moment you want a real outer stroke or a displacement you drop back to url(#…) and build the graph yourself. And since CSS cannot animate a primitive’s attributes, that graph animates from JavaScript, which is why every demo above sets them frame by frame.
For HTML, those filter <defs> still need a home. Keep them in an inline SVG that stays in the render tree but out of the way:
<svg class="filter-defs" aria-hidden="true">
<defs>
<filter id="my-filter">…</filter>
</defs>
</svg>
<h1 class="filtered">Text</h1>
.filter-defs {
position: absolute;
width: 0;
height: 0;
overflow: hidden;
}
A few things worth knowing:
- Do not put the filter
<defs>inside an SVG set todisplay: none. Chromium drops the filter (afeFlood/feCompositestroke renders as nothing). Keep the SVG in the render tree but out of the way: zero-size andposition: absolute, orvisibility: hidden. - Filter region (
x,y,width,height) is relative to the element’s bounding box. If a glow bleeds outside, expand it:x="-20%" y="-20%" width="140%" height="140%". color-interpolation-filters="sRGB"on every filter. The defaultlinearRGBmakes blending modes look washed out.feImagecan fill type with a tiled pattern (feImage+feTileclipped toSourceAlpha), but it is the flakiest primitive on HTML content. Safari is unreliable with it and Firefox historically wanted external references. When a pattern fill has to ship,background-clip: textis the dependable route.- Filters are not free.
feTurbulence, displacement and lighting are the expensive ones, and a page with several animated filters will heat a laptop fast. Drive any animation withrequestAnimationFrame, and gate it behind anIntersectionObserverso a filter only recomputes while it is actually on screen. - Respect
prefers-reduced-motion. Every looping filter on this page checksmatchMedia('(prefers-reduced-motion: reduce)')and, when it matches, settles on a representative still frame instead of animating. The effect is still visible, it just stops moving. The same gating that pauses animation off-screen is the right place to hang this check. - Safari handles all of this correctly in 2026,
feImageaside. The caveats in Dirk’s 2015 article are mostly resolved.