SVG gradients: linearGradient, radialGradient and their stops

An SVG gradient is an element you define once in defs and paint with fill="url(#id)" or stroke="url(#id)". Its stops set the colours, and a handful of attributes decide where they land.

To put a gradient on an SVG shape, define a <linearGradient> or <radialGradient> with an id inside <defs>, give it two or more <stop> elements, and point the shape at it with fill="url(#id)".

<svg viewBox="0 0 320 140">
  <defs>
    <linearGradient id="grad">
      <stop offset="0" stop-color="#ff7a18" />
      <stop offset="1" stop-color="#6a3cff" />
    </linearGradient>
  </defs>
  <rect width="320" height="140" rx="16" fill="url(#grad)" />
</svg>

Try it. Change the direction, the colours and the offsets, and the markup below the controls updates.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SVG linearGradient</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  svg { display: block; width: 100%; max-width: 360px; height: auto; margin: 0 auto 12px; }
  .controls { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 14px; max-width: 360px; margin: 0 auto; font-size: 14px; }
  label { display: flex; flex-direction: column; gap: 3px; }
  input[type=range], select { width: 100%; }
  input[type=color] { width: 100%; height: 30px; padding: 0; border: 1px solid #ccd; }
  pre { max-width: 360px; margin: 12px auto 0; padding: 10px; background: #1d2330; color: #e6e9ef;
        border-radius: 8px; font-size: 12px; overflow-x: auto; }
</style>
</head>
<body>
<svg viewBox="0 0 320 140" aria-label="Rectangle filled with a gradient">
  <defs>
    <linearGradient id="grad" x1="0" y1="0" x2="1" y2="0">
      <stop id="s1" offset="0" stop-color="#ff7a18" />
      <stop id="s2" offset="1" stop-color="#6a3cff" />
    </linearGradient>
  </defs>
  <rect x="0" y="0" width="320" height="140" rx="16" fill="url(#grad)" />
</svg>

<div class="controls">
  <label>Direction
    <select id="dir">
      <option value="0 0 1 0">Left to right</option>
      <option value="0 0 0 1">Top to bottom</option>
      <option value="0 0 1 1">Corner to corner</option>
    </select>
  </label>
  <label>Stop 2 opacity <input id="op" type="range" min="0" max="1" step="0.05" value="1"></label>
  <label>Stop 1 colour <input id="c1" type="color" value="#ff7a18"></label>
  <label>Stop 2 colour <input id="c2" type="color" value="#6a3cff"></label>
  <label>Stop 1 offset <input id="o1" type="range" min="0" max="1" step="0.05" value="0"></label>
  <label>Stop 2 offset <input id="o2" type="range" min="0" max="1" step="0.05" value="1"></label>
</div>
<pre id="code"></pre>

<script>
  const $ = (id) => document.getElementById(id);
  const grad = $('grad'), s1 = $('s1'), s2 = $('s2');

  function update() {
    // x1 y1 x2 y2 are fractions of the shape's box (gradientUnits="objectBoundingBox")
    const [x1, y1, x2, y2] = $('dir').value.split(' ');
    grad.setAttribute('x1', x1); grad.setAttribute('y1', y1);
    grad.setAttribute('x2', x2); grad.setAttribute('y2', y2);
    s1.setAttribute('offset', $('o1').value);
    s1.setAttribute('stop-color', $('c1').value);
    s2.setAttribute('offset', $('o2').value);
    s2.setAttribute('stop-color', $('c2').value);
    s2.setAttribute('stop-opacity', $('op').value);
    // show the current markup
    $('code').textContent =
      `<linearGradient id="grad" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}">\n` +
      `  <stop offset="${$('o1').value}" stop-color="${$('c1').value}" />\n` +
      `  <stop offset="${$('o2').value}" stop-color="${$('c2').value}" stop-opacity="${$('op').value}" />\n` +
      `</linearGradient>\n<rect ... fill="url(#grad)" />`;
  }
  document.querySelectorAll('input, select').forEach((el) => el.addEventListener('input', update));
  update();
</script>
</body>
</html>
A linearGradient with two stops. Set stop 2 opacity to 0 to fade the rectangle out.

A CSS gradient such as linear-gradient() does not work as an SVG fill. The fill property takes a colour or a url() to a paint server, and the gradient element is that paint server.

Plain fills, strokes and holes are covered in CSS fill on SVG.

How linearGradient places its colours

A linear gradient runs along a line from (x1, y1) to (x2, y2). The defaults are x1="0" y1="0" x2="1" y2="0", so with no attributes the colours run left to right across the shape.

Stops sit along the gradient line (left). A radial gradient grows from the focal point out to the circle (right).
Stops sit along the gradient line (left). A radial gradient grows from the focal point out to the circle (right).

Each point on the line gets a colour from the stops, and that colour is carried straight across at a right angle to the line. That is why only the end points matter for the direction:

Direction Attributes
Left to right x1="0" x2="1" (the default)
Right to left x1="1" x2="0"
Top to bottom x2="0" y2="1"
Corner to corner x2="1" y2="1"

Numbers from 0 to 1 and percentages both work, so x2="100%" means the same as x2="1".

stop offset, stop-color and stop-opacity

Every <stop> has three settings. offset says where it sits on the line, from 0 (or 0%) to 1 (or 100%). stop-color sets its colour, and stop-opacity makes that colour see-through.

  • Offsets are clamped. A value above 1 is treated as 1 and a value below 0 as 0.
  • Offsets never go backwards. If a stop's offset is smaller than the one before it, it is moved up to match. Two stops at the same offset give a hard edge, which is how you draw stripes.
  • One stop paints a flat colour. A gradient with no stops paints nothing at all.
  • stop-color is a CSS property. A class or a custom property can set it, so one stylesheet can theme every gradient.
.brand-start { stop-color: var(--brand-a); }
.brand-end   { stop-color: var(--brand-b); }

For a fade, keep the same colour on both stops and change only stop-opacity. Fading a colour to transparent can pass through a darker, greyer middle.

radialGradient: cx, cy, r and the focal point

A radial gradient is a set of circles. cx, cy and r set the outer circle, where the stop at offset 1 sits. fx and fy set the focal point, where offset 0 sits.

cx, cy and r default to 50% of the box, and fx, fy default to cx, cy.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SVG radialGradient and spreadMethod</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  svg { display: block; width: 100%; max-width: 220px; height: auto; margin: 0 auto 12px; border-radius: 12px; }
  .controls { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 14px; max-width: 360px; margin: 0 auto; font-size: 14px; }
  label { display: flex; flex-direction: column; gap: 3px; }
  input[type=range], select { width: 100%; }
  pre { max-width: 360px; margin: 12px auto 0; padding: 10px; background: #1d2330; color: #e6e9ef;
        border-radius: 8px; font-size: 12px; overflow-x: auto; }
</style>
</head>
<body>
<svg viewBox="0 0 200 200" aria-label="Square filled with a radial gradient">
  <defs>
    <radialGradient id="glow" cx="0.5" cy="0.5" r="0.25" fx="0.5" fy="0.5" spreadMethod="pad">
      <stop offset="0" stop-color="#fff6c2" />
      <stop offset="0.5" stop-color="#ff9f1c" />
      <stop offset="1" stop-color="#7b1e5a" />
    </radialGradient>
  </defs>
  <rect width="200" height="200" fill="url(#glow)" />
</svg>

<div class="controls">
  <label>spreadMethod
    <select id="spread">
      <option>pad</option><option>reflect</option><option>repeat</option>
    </select>
  </label>
  <label>r (radius) <input id="r" type="range" min="0.1" max="0.7" step="0.05" value="0.25"></label>
  <label>fx (focal x) <input id="fx" type="range" min="0.3" max="0.7" step="0.05" value="0.5"></label>
  <label>fy (focal y) <input id="fy" type="range" min="0.3" max="0.7" step="0.05" value="0.5"></label>
</div>
<pre id="code"></pre>

<script>
  const glow = document.getElementById('glow');
  const code = document.getElementById('code');

  function update() {
    ['r', 'fx', 'fy'].forEach((k) => glow.setAttribute(k, document.getElementById(k).value));
    // spreadMethod decides what fills the area outside the circle of radius r
    glow.setAttribute('spreadMethod', document.getElementById('spread').value);
    const a = (k) => glow.getAttribute(k);
    code.textContent =
      `<radialGradient id="glow" cx="0.5" cy="0.5"\n` +
      `  r="${a('r')}" fx="${a('fx')}" fy="${a('fy')}"\n` +
      `  spreadMethod="${a('spreadMethod')}">`;
  }
  document.querySelectorAll('input, select').forEach((el) => el.addEventListener('input', update));
  update();
</script>
</body>
</html>
Shrink r to see spreadMethod at work, and move the focal point off centre for a lit-from-the-side look.

Moving fx and fy away from the centre makes a highlight like light hitting a ball. SVG 2 also adds fr, the radius of the focal circle. Everything inside fr gets the first stop's colour.

spreadMethod: what happens past the end

When the gradient line or circle is smaller than the shape, some of the shape lies outside it. spreadMethod decides what goes there.

The same two stops over a line that covers the first 30% of the bar.
The same two stops over a line that covers the first 30% of the bar.
  • pad (the default) keeps the colour of the end stop.
  • reflect plays the stops backwards, then forwards again, with no seams.
  • repeat starts again at the first stop, with a hard edge at every seam.

With repeat, a short line and four stops make even stripes without extra shapes: colour A at 0 and 0.5, colour B at 0.5 and 1.

gradientUnits: objectBoundingBox or userSpaceOnUse

gradientUnits decides what the numbers in x1, cx, r and the rest mean. The default, objectBoundingBox, measures them against each shape's own box. userSpaceOnUse measures them in the drawing's coordinates.

Left: every bar gets the full gradient. Right: one gradient spans the drawing, and the flat line gets it too.
Left: every bar gets the full gradient. Right: one gradient spans the drawing, and the flat line gets it too.

Bounding-box units are handy for one shape: 0 and 1 are its edges whatever its size. They have two costs:

  • Straight lines lose the gradient. A horizontal line's box has no height, so the gradient cannot be mapped and the stroke is not painted. Add a fallback, as in stroke="url(#g) #ef476f", or switch units.
  • Shared gradients restart. Three bars of different widths each show the full range, instead of one gradient running under all three.

With userSpaceOnUse, give real coordinates:

<linearGradient id="line" gradientUnits="userSpaceOnUse"
                x1="0" y1="0" x2="320" y2="0">

gradientTransform: rotating and scaling

gradientTransform takes the same functions as the SVG transform attribute: rotate(), scale(), translate() and skewX(). It moves the gradient, not the shape.

<linearGradient id="diag" gradientTransform="rotate(45 0.5 0.5)">

The two extra numbers in rotate() are the centre of rotation. Without them the gradient turns around the top-left corner and part of the shape can end up past the line's end.

With bounding-box units the rotation happens in a 1 by 1 square that is then stretched to the shape.

On a 400 by 100 rectangle, rotate(45 0.5 0.5) left the colour bands at 14 degrees from horizontal in Chromium and Firefox, not 45.

For an exact angle on a shape that is not square, use userSpaceOnUse and rotate around the shape's real centre.

Gradient text, gradient strokes and reusing a gradient

fill and stroke work the same way on every shape, including <text>. Set fill="url(#id)" on the text and the letters take the gradient. For gradient text in ordinary HTML headings, use background-clip: text from CSS background-clip instead.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Gradient stat card</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #0f1422; color: #e6e9ef; }
  .card { max-width: 380px; margin: 0 auto; padding: 16px; border-radius: 16px; background: #182033; }
  .top { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
  .title { width: 190px; max-width: 60%; height: auto; }
  .ring { width: 88px; height: 88px; flex: none; }
  .chart { display: block; width: 100%; height: auto; margin-top: 12px; }
  .buttons { display: flex; gap: 8px; margin-top: 12px; }
  button { flex: 1; padding: 9px; border: 0; border-radius: 8px; background: #2a3550; color: #e6e9ef; font: inherit; cursor: pointer; }
</style>
</head>
<body>
<div class="card">
  <div class="top">
    <!-- Gradient text: the letters are filled with the gradient -->
    <svg class="title" viewBox="0 0 190 44" aria-label="Weekly visits">
      <defs>
        <linearGradient id="textGrad" x1="0" y1="0" x2="1" y2="0">
          <stop offset="0" stop-color="#ffd166" />
          <stop offset="1" stop-color="#ef476f" />
        </linearGradient>
      </defs>
      <text x="0" y="32" font-size="28" font-weight="800" fill="url(#textGrad)">Weekly visits</text>
    </svg>

    <!-- Gradient stroke on a circle: the ring shows progress -->
    <svg class="ring" viewBox="0 0 88 88" aria-label="Goal progress">
      <defs>
        <linearGradient id="ringGrad" x1="0" y1="0" x2="1" y2="1">
          <stop offset="0" stop-color="#06d6a0" />
          <stop offset="1" stop-color="#118ab2" />
        </linearGradient>
      </defs>
      <circle cx="44" cy="44" r="36" fill="none" stroke="#2a3550" stroke-width="10" />
      <circle id="ring" cx="44" cy="44" r="36" fill="none" stroke="url(#ringGrad)" stroke-width="10"
              stroke-linecap="round" pathLength="100" stroke-dasharray="0 100" transform="rotate(-90 44 44)" />
      <text id="pct" x="44" y="50" text-anchor="middle" font-size="17" font-weight="700" fill="#e6e9ef">0%</text>
    </svg>
  </div>

  <svg class="chart" viewBox="0 0 320 130" aria-label="Line chart">
    <defs>
      <!-- userSpaceOnUse: one gradient in chart pixels, so a flat line still gets it -->
      <linearGradient id="lineGrad" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="320" y2="0">
        <stop offset="0" stop-color="#ffd166" />
        <stop offset="1" stop-color="#ef476f" />
      </linearGradient>
      <!-- The area under the line fades out with stop-opacity -->
      <linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
        <stop offset="0" stop-color="#ef476f" stop-opacity="0.45" />
        <stop offset="1" stop-color="#ef476f" stop-opacity="0" />
      </linearGradient>
    </defs>
    <path id="area" fill="url(#areaGrad)" />
    <path id="line" fill="none" stroke="url(#lineGrad)" stroke-width="4" stroke-linejoin="round" stroke-linecap="round" />
  </svg>

  <div class="buttons">
    <button id="rand" type="button">New data</button>
    <button id="flat" type="button">Flat week</button>
  </div>
</div>

<script>
  const W = 320, H = 130, PAD = 8;

  function draw(values) {
    const step = (W - 2 * PAD) / (values.length - 1);
    const pts = values.map((v, i) => [PAD + i * step, H - PAD - v * (H - 2 * PAD)]);
    const d = 'M' + pts.map((p) => p.join(' ')).join(' L');
    document.getElementById('line').setAttribute('d', d);
    document.getElementById('area').setAttribute('d', d + ` L${W - PAD} ${H} L${PAD} ${H} Z`);
    // the ring shows the average as a percentage
    const pct = Math.round(values.reduce((a, b) => a + b, 0) / values.length * 100);
    document.getElementById('ring').setAttribute('stroke-dasharray', `${pct} 100`);
    document.getElementById('pct').textContent = pct + '%';
  }

  document.getElementById('rand').addEventListener('click', () =>
    draw(Array.from({ length: 7 }, () => 0.15 + Math.random() * 0.8)));
  document.getElementById('flat').addEventListener('click', () => draw(Array(7).fill(0.5)));
  draw([0.3, 0.45, 0.4, 0.62, 0.55, 0.78, 0.7]);
</script>
</body>
</html>
Gradient text, a gradient ring and a gradient line. Press Flat week: the line stays painted because it uses userSpaceOnUse.

In this card:

  • The title is SVG text with fill="url(#textGrad)".
  • The ring is a circle with stroke="url(#ringGrad)". Its progress arc comes from stroke-dasharray, explained in SVG stroke-dasharray.
  • The line uses userSpaceOnUse, so a flat week still shows it.
  • The area under the line fades out with stop-opacity.

To reuse a gradient's stops with a different direction, point a new gradient at it with href. The new one inherits the stops and overrides only the attributes you set:

<linearGradient id="down" href="#grad" x2="0" y2="1" />

When it does not work

What you see Cause Fix
The shape is black The fill was set to a CSS linear-gradient(), which is invalid for fill Define a <linearGradient> and use url(#id)
The shape has no fill at all The id in url(#id) does not match, or the gradient has no stops Check the id spelling; add <stop> elements
A straight line with a gradient stroke vanishes Bounding-box units on a box with no height or width gradientUnits="userSpaceOnUse"
Every shape shows the whole gradient Each shape has its own bounding box Switch to userSpaceOnUse
The gradient ignores its definition It sits in an svg with display: none Hide that svg with zero size instead
A shape shows another gradient's colours Two gradients share one id; the first in the page wins Give every gradient a unique id
A stop seems to be missing Its offset is lower than the stop before it Put stops in increasing offset order
The rotated angle looks wrong gradientTransform is stretched with the bounding box Use userSpaceOnUse for exact angles

The duplicate id case shows up when the same inline SVG icon is pasted twice, or when two components each define id="grad".

Gradients are easy to see and hard to judge from a description, and the interactive ones need their scripts. A screenshot cannot be played with, and an .html attachment may open as plain code on a phone.

To send the working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to can move the sliders and press the buttons themselves. If you change the code later, the same link shows the new version.

Questions people ask

Can I use a CSS linear-gradient() as an SVG fill?

No. fill and stroke accept a colour, none, or a url() that points to a paint server such as linearGradient. A declaration like fill: linear-gradient(...) is invalid and ignored, so the shape keeps its previous fill, black by default. Define a linearGradient element and use fill: url(#id) instead.

Why does my gradient stroke disappear on a straight line?

A horizontal or vertical line has a bounding box with zero height or zero width. With the default gradientUnits="objectBoundingBox" the gradient cannot be mapped onto that box, so the stroke is not painted. Set gradientUnits="userSpaceOnUse" and give x1, y1, x2, y2 in drawing coordinates, or add a fallback colour after the url().

How do I change the angle of an SVG linear gradient?

Move the end points. x1="0" y1="0" x2="0" y2="1" runs top to bottom, and x2="1" y2="1" runs corner to corner. You can also rotate with gradientTransform, but with the default bounding-box units the rotation is stretched with the shape, so a wide shape will not show the exact angle you typed.

Can two SVGs on the same page share one gradient?

Yes. url(#id) looks the id up in the whole document, so a gradient defined in one inline svg can paint shapes in another. Keep the defining svg rendered: in Chromium and Firefox a gradient inside an svg with display: none painted nothing in our test. Hide it with width="0" height="0" and position: absolute instead.

How do I make a gradient fade to transparent?

Give the last stop stop-opacity="0". Keep the same stop-color on both stops so the fade does not pass through a muddy middle colour.

Keep reading