Skip to content

SVG Visual Effects

A UI principle for coding agents. Also covers svg filters, goo effect, metaballs, svg gradients, linearGradient, textPath, and 10 more.

Show all 16 aliases

svg filters, goo effect, metaballs, svg gradients, linearGradient, textPath, curved text, svg patterns, notched corners, outer corners, feTurbulence, liquid ui, feDropShadow, svg drop shadow, SourceAlpha, feMerge

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 via filter="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
  • feGaussianBlur is the primitive you will use most. stdDeviation sets 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

  • feDropShadow does the whole job in one primitive. dx and dy offset it, stdDeviation softens it, flood-color and flood-opacity colour 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 for SourceGraphic and you get a coloured smear rather than a shadow
  • feMerge stacks 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 -9 means alpha * 19 - 9, so faint overlap regions clamp to solid and thin haze clamps to transparent
  • A final feComposite operator="atop" with SourceGraphic re-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 stdDeviation against shape size: bigger blur means merging starts from further away but rounds off fine detail

Organic distortion

  • feTurbulence generates procedural noise (baseFrequency sets the grain, numOctaves the detail); feDisplacementMap then shifts the source's pixels by that noise field, scale controlling 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 with fill="url(#id)" or stroke="url(#id)"
  • Direction is a line, not an angle: x1 y1 to x2 y2 define the axis, and each <stop> sits at an offset along it with a stop-color (plus optional stop-opacity)
  • gradientUnits="objectBoundingBox" (the default) measures those coordinates as fractions of the painted shape's box, so one definition adapts to any shape. userSpaceOnUse pins 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 offset values 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 background
  • patternUnits="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 web
  • startOffset positions 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, and letter-spacing have 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-radius only 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-shape with its scoop and notch values, 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-radius so 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.md has 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 animation declarations sit in the gate, so a reduced-motion user gets the full artwork, still
  • Looping feTurbulence and feDisplacementMap animation 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

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-ui-principle({ topic: "svg-visual-effects" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems