Core Philosophy
- SVG owns a paint layer that CSS cannot reach: pixel-level filters, geometry-aware gradients, tiled pattern fills, and text that flows along a curve
- These are finishing effects, not workhorses. Motion mechanics live in the SVG animation doc; this doc covers what the shapes are painted with
- Everything here is referenced by id through
url(#...), so the same collision rule applies throughout: in a reusable component, every definition needs a per-instance id
Filters: the pipeline model
- A
<filter>in<defs>holds a chain of primitives, applied to any element viafilter="url(#id)". Each primitive consumes the previous result and emits a new image, top to bottom - CSS
filter: blur()and friends are the shorthand tier of the same filter model, each function defined against an equivalent primitive rather than literally compiled into one. Dropping into raw SVG buys full control of the chain at the cost of verbosity feGaussianBluris the primitive you will use most.stdDeviationsets the standard deviation of the Gaussian on each axis, not a blur radius, so the visible spread extends a few multiples of the number you set;in="SourceGraphic"reads the element as painted,in="SourceAlpha"reads only its opacity, which yields a black silhouette useful for shadows
<svg viewBox="0 0 120 120">
<defs>
<filter id="soft-blur" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="4" />
</filter>
</defs>
<circle cx="60" cy="60" r="30" fill="currentColor" filter="url(#soft-blur)" />
</svg>
- The filter region clips. Defaults are
x="-10%" y="-10%" width="120%" height="120%"relative to the filtered element, so a strong blur gets sliced off at a rectangular edge. Widen the region explicitly, as above, whenever the output grows beyond the source shape
Shadows, and what they teach about the pipeline
feDropShadowdoes the whole job in one primitive.dxanddyoffset it,stdDeviationsoftens it,flood-colorandflood-opacitycolour it
<filter id="lift" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow
dx="0"
dy="4"
stdDeviation="4"
flood-color="oklch(0 0 0)"
flood-opacity="0.25"
/>
</filter>
- Write the chain by hand once anyway, because it is the clearest demonstration of the filter model: blur the source alpha, offset the blurred result, then merge that result underneath the original graphic
<filter id="lift-manual" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceAlpha" stdDeviation="4" result="blur" />
<feOffset in="blur" dy="4" result="shadow" />
<feMerge>
<feMergeNode in="shadow" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
in="SourceAlpha"is the load-bearing part. It reads only the source's opacity, so the blurred copy comes out black instead of carrying the shape's own colour. Swap it forSourceGraphicand you get a coloured smear rather than a shadowfeMergestacks its nodes bottom to top, so the shadow node comes first and the untouched graphic sits on top of it- Widen the filter region for either version. An offset, blurred shadow grows well past the source bounds and the default region slices it off
The goo recipe
The signature filter trick: nearby shapes visually fuse into one liquid blob.
- Two steps. First blur the shapes together, then run the result through
feColorMatrix, multiplying alpha up and offsetting it down. The steep alpha curve snaps the blurry haze back to a hard edge, but the edge now follows the merged silhouette - The matrix keeps RGB untouched and only rewrites the alpha row:
0 0 0 19 -9meansalpha * 19 - 9, so faint overlap regions clamp to solid and thin haze clamps to transparent - A final
feComposite operator="atop"withSourceGraphicre-draws the crisp originals on top of the goo, keeping inner detail sharp
<filter id="goo" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceGraphic" stdDeviation="8" result="blur" />
<feColorMatrix
in="blur"
mode="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>
- Apply the filter to a group, not to individual shapes; merging only happens between siblings inside the same filtered group
- Where it earns its keep: a pill indicator that stretches gooily between tabs, a loading dot cluster, a cursor blob absorbing a hovered target, a menu tip melting into its panel
- Tune
stdDeviationagainst shape size: bigger blur means merging starts from further away but rounds off fine detail
Organic distortion
feTurbulencegenerates procedural noise (baseFrequencysets the grain,numOctavesthe detail);feDisplacementMapthen shifts the source's pixels by that noise field,scalecontrolling how far- The pair produces hand-drawn wobble, water refraction, heat shimmer, and torn-paper edges from perfectly clean geometry
<filter id="wobble">
<feTurbulence type="turbulence" baseFrequency="0.05" numOctaves="2" result="noise" />
<feDisplacementMap in="SourceGraphic" in2="noise" scale="6" />
</filter>
- Performance warning: filters rasterize. The browser flattens the element to a bitmap and re-runs the pipeline whenever the input changes, and how much of that lands on the CPU versus the GPU varies by browser and primitive. One filtered group is usually fine; animating filter attributes on many elements, or filtering a large area every frame, is a common jank source. Profile before trusting either assumption, and prefer animating cheap transforms on shapes inside a static filter over animating the filter itself
Gradients
- SVG shapes cannot take CSS gradients as
fill. They use<linearGradient>and<radialGradient>definitions instead, applied withfill="url(#id)"orstroke="url(#id)" - Direction is a line, not an angle:
x1 y1tox2 y2define the axis, and each<stop>sits at anoffsetalong it with astop-color(plus optionalstop-opacity) gradientUnits="objectBoundingBox"(the default) measures those coordinates as fractions of the painted shape's box, so one definition adapts to any shape.userSpaceOnUsepins the gradient to viewBox coordinates, which lets several shapes share one continuous gradient
<defs>
<linearGradient id="sheen" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="oklch(0.95 0.05 250)" />
<stop offset="100%" stop-color="oklch(0.55 0.2 265)" />
</linearGradient>
</defs>
<rect x="8" y="8" width="104" height="64" rx="12" fill="url(#sheen)" />
- Stops and gradient coordinates are animatable: sliding
offsetvalues or the axis endpoints produces shimmer sweeps and animated duotone washes without touching the geometry - Gradient ids collide exactly like mask and filter ids. Two components each defining
id="sheen"in one document is invalid markup, and the second silently paints with the first's colors; generate a unique id per instance
Patterns
<pattern>tiles its content across any shape that uses it as a fill, the SVG counterpart of a repeating backgroundpatternUnits="userSpaceOnUse"sizes the tile in viewBox coordinates, which is almost always what you want; the default bounding-box units make the tile scale with each shape it fills- Practical uses: dot grids and blueprint lines behind hero graphics, hatching on chart segments, subtle texture on large brand shapes
<defs>
<pattern id="dots" width="8" height="8" patternUnits="userSpaceOnUse">
<circle cx="1.5" cy="1.5" r="1.5" fill="currentColor" opacity="0.25" />
</pattern>
</defs>
<rect width="240" height="120" fill="url(#dots)" />
Text on a curve
<textPath href="#curve-id">inside a<text>element flows characters along a referenced path. This is the only native way to set curved text on the webstartOffsetpositions the text along the path (percentage or length); animating it slides the text around the curve, which is the whole trick behind circular rotating badges- Characters that overrun the path simply do not render, so the path length,
font-size, andletter-spacinghave to be balanced by eye for each string. There is no auto-fit - Size the host
<svg>in rem so the whole graphic, text included, scales with the user's font-size preference
<svg viewBox="0 0 400 120" style="inline-size: 24rem">
<defs>
<path id="arc" d="M 20,110 Q 200,10 380,110" fill="none" />
</defs>
<text>
<textPath href="#arc" startOffset="50%" text-anchor="middle">Along the curve</textPath>
</text>
</svg>
Concave corners
border-radiusonly rounds convex corners. Where a tab meets its panel, or a protruding close button joins a dialog edge, the joint needs a corner that curves the opposite way. CSS only recently gained one for this,corner-shapewith itsscoopandnotchvalues, and support is still too narrow to rely on- The fix is a small SVG wedge: start at one end of the corner, sweep a quarter-circle arc (
A r,r 0 0 1 ...) toward the other end, then square off the outside and close. Filled with the surface color and butted against the joint, it reads as the surface flowing smoothly around the corner - One 8x8 asset covers all four orientations via rotation
<svg viewBox="0 0 8 8" width="8" height="8" aria-hidden="true">
<!-- concave fillet: arc across, then fill the outer corner -->
<path fill="currentColor" d="M 8,0 A 8,8 0 0 1 0,8 L 8,8 Z" />
</svg>
- Match the wedge radius to the element's own
border-radiusso all corners, convex and concave, share one radius and the shapes read as a single piece - The goo filter is the soft alternative for the same problem: run the tab and panel through one goo group and the join melts into a smooth fillet on its own, at the cost of a filter. Use geometry when the join is static, goo when the joined parts move (see the SVG animation doc for animating the connecting path itself)
Restraint
- Every technique here is a signature moment, not a default. One goo interaction, one curved headline, or one textured panel per surface is the ceiling
- These effects announce themselves. A second gradient sweep or a second wobble filter on the same screen stops reading as craft and starts reading as decoration
- Ship the plain version first. Add the effect only where it explains something (a merge, a connection, a material) that the plain version could not
Reduced motion
Several effects here are static paint until you animate them, and that animation is almost always decorative.
- Stop the movement and keep the paint. A gradient sweep freezes into a gradient, a rotating badge stops rotating and stays readable, a wobble filter settles at one distortion
- Never let the reduced path remove the fill, hide the text or blank the filter. The user asked for less movement, not less content
- That split falls out of the markup on its own if you declare the animation inside
@media (prefers-reduced-motion: no-preference)and leave the paint in the ordinary rules. The feature has exactly two values, so a browser that cannot evaluate the query matches neither and renders the static artwork.accessibility.mdhas the argument in full
.sheen-sweep {
fill: url(#sheen);
}
.wobble {
filter: url(#wobble);
}
@media (prefers-reduced-motion: no-preference) {
.sheen-sweep {
animation: sweep 3000ms linear infinite;
}
.rotating-badge {
animation: spin 12000ms linear infinite;
}
.wobble {
animation: shimmer 4000ms ease-in-out infinite alternate;
}
}
- The ungated rules hold everything that paints: the gradient fill, the filter reference, the badge itself. Only the
animationdeclarations sit in the gate, so a reduced-motion user gets the full artwork, still - Looping
feTurbulenceandfeDisplacementMapanimation is the one to be strictest about. Continuous procedural distortion is uncomfortable for motion-sensitive users and carries no information, so drop it rather than slow it
Common mistakes
- A blurred or shadowed element sliced off at a rectangular edge, because the filter region still has its default bounds
- Applying the goo filter to individual shapes instead of to their shared parent group, so nothing ever merges
- Using
in="SourceGraphic"for a shadow's blur and getting a coloured smear; a shadow reads the source alpha - Two components each defining
id="sheen", so the second one silently paints with the first one's colours - Trying to fill an SVG shape with a CSS gradient. SVG shapes take
<linearGradient>and<radialGradient>references, not CSS gradient functions - A
<pattern>left on the default bounding-box units, so the tile rescales for every shape it fills instead of holding one size - Text that runs off a
<textPath>and silently stops rendering, because nothing auto-fits the string to the curve - Animating filter attributes on many elements at once, which re-rasterizes each of them every frame
- Stacking two or more signature effects on one screen, which reads as decoration rather than craft