SVG rendering hints: which ones change the pixels

SVG has a handful of properties that tell the browser how to draw rather than what to draw. Two of them change what you see every time, one changes colour maths, and two did nothing visible in our tests.

Two SVG rendering properties change what you see: shape-rendering: crispEdges switches off anti-aliasing so edges land on whole pixels, and vector-effect: non-scaling-stroke keeps a line the same width however large the SVG is drawn.

A third pair, color-interpolation and color-interpolation-filters, changes the maths behind gradients and filters.

Pick a value and look at the magnified pixels on the right.

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>shape-rendering, magnified</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .opts { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
  .opts label {
    font: 600 13px ui-monospace, Consolas, monospace; padding: 7px 10px; border-radius: 8px;
    background: #fff; border: 1px solid #d5d9e0; cursor: pointer;
  }
  .opts input { margin: 0 4px 0 0; }
  .row { display: flex; gap: 14px; align-items: flex-start; flex-wrap: wrap; }
  .live { background: #fff; border: 1px solid #d5d9e0; }
  #zoom {
    width: 100%; max-width: 384px; background: #fff; border: 1px solid #d5d9e0;
    image-rendering: pixelated;  /* show each pixel as a square, no smoothing */
  }
  p { font-size: 13px; color: #4b5563; margin: 8px 0 0; }
  small { display: block; font-size: 12px; color: #6b7280; margin-bottom: 4px; }
</style>
</head>
<body>
<div class="opts" id="opts">
  <label><input type="radio" name="sr" value="auto" checked>auto</label>
  <label><input type="radio" name="sr" value="crispEdges">crispEdges</label>
  <label><input type="radio" name="sr" value="geometricPrecision">geometricPrecision</label>
  <label><input type="radio" name="sr" value="optimizeSpeed">optimizeSpeed</label>
</div>

<div class="row">
  <div>
    <small>Real size</small>
    <svg id="art" class="live" width="64" height="40" viewBox="0 0 64 40" shape-rendering="auto">
      <!-- a 1px line centred on y = 6: half of it falls in each of two pixel rows -->
      <line x1="2" y1="6" x2="62" y2="6" stroke="#111" stroke-width="1"/>
      <!-- a square that starts half-way into a pixel -->
      <rect x="4.5" y="12.5" width="14" height="14" fill="#1d4ed8"/>
      <circle cx="36" cy="22" r="10" fill="#0f766e"/>
      <line x1="50" y1="36" x2="62" y2="12" stroke="#b45309" stroke-width="1.5"/>
    </svg>
  </div>
  <div style="flex: 1; min-width: 240px;">
    <small>Same pixels, 6 times larger</small>
    <canvas id="zoom" width="64" height="40"></canvas>
  </div>
</div>
<p id="note"></p>

<script>
  const art = document.getElementById('art');
  const zoom = document.getElementById('zoom').getContext('2d');
  const note = document.getElementById('note');
  const notes = {
    auto: 'auto: edges are anti-aliased, so half-covered pixels turn grey.',
    crispEdges: 'crispEdges: each pixel is fully in or out. Hard edges, jagged curves.',
    geometricPrecision: 'geometricPrecision: a hint for accurate edges. Compare it with auto.',
    optimizeSpeed: 'optimizeSpeed: a hint for speed. Some engines draw it like crispEdges.'
  };

  // Draw the SVG into a small canvas, then CSS scales the canvas up
  function magnify() {
    const img = new Image();
    img.onload = () => { zoom.clearRect(0, 0, 64, 40); zoom.drawImage(img, 0, 0); };
    img.src = 'data:image/svg+xml,' + encodeURIComponent(new XMLSerializer().serializeToString(art));
  }

  document.getElementById('opts').addEventListener('change', (e) => {
    art.setAttribute('shape-rendering', e.target.value);  // inherited by every shape inside
    note.textContent = notes[e.target.value];
    magnify();
  });

  note.textContent = notes.auto;
  magnify();
</script>
</body>
</html>
The same small SVG with each shape-rendering value, drawn at real size and six times larger.

With auto, the circle's edge is made of grey pixels and the thin line is two faint rows. With crispEdges, every pixel is fully coloured or empty.

What we measured, and where

All numbers on this page come from the Playwright builds of Chromium, Firefox and WebKit, run on Windows. We drew test shapes, took screenshots and counted pixels.

We did not test other browsers or older versions, so read the results as "what these three engines did", not as a support table.

Property Value tested Visible change Engines
shape-rendering crispEdges Yes, no anti-aliasing Chromium, Firefox, WebKit
shape-rendering geometricPrecision None, same pixels as auto Chromium, Firefox, WebKit
shape-rendering optimizeSpeed Same as crispEdges Chromium, Firefox
shape-rendering optimizeSpeed None, same as auto WebKit
vector-effect non-scaling-stroke Yes Chromium, Firefox, WebKit
color-interpolation linearRGB on a gradient Yes, brighter middle Chromium, Firefox, WebKit
color-interpolation-filters sRGB Yes, darker result Chromium, Firefox, WebKit
color-rendering optimizeSpeed, optimizeQuality None Chromium, Firefox, WebKit
buffered-rendering static None Chromium, Firefox, WebKit

shape-rendering: crispEdges for lines that sit on pixels

A shape edge rarely lines up with the pixel grid. When a pixel is only partly covered, the browser paints it at partial strength. That is anti-aliasing, and it is why curves look smooth.

It also blurs thin straight lines. A 1px stroke centred on y="260" spans from 259.5 to 260.5, so it covers half of two pixel rows.

A 1px horizontal line: two half-grey rows with auto, one black row with crispEdges.
A 1px horizontal line: two half-grey rows with auto, one black row with crispEdges.

We measured exactly that. With auto, rows 259 and 260 came out as grey values 128 and 127 in all three engines. With crispEdges, row 260 was pure black (0) and row 259 white (255).

The cost shows on curves. A black circle drawn with auto produced 92 to 99 distinct grey levels, depending on the engine. With crispEdges it produced 2: black and white.

Use it on gridlines, borders, bars and axis ticks. Keep auto for icons, curves and diagonal lines.

The property is inherited, so one attribute on the root <svg> or a <g> covers every shape inside:

<svg viewBox="0 0 200 100" shape-rendering="crispEdges">
  <line x1="0" y1="50" x2="200" y2="50" stroke="#000"/>
</svg>

It is also a CSS property, so a stylesheet rule works the same way:

.grid { shape-rendering: crispEdges; }

Text is not affected. For the HTML image version of this idea, see image-rendering.

geometricPrecision and optimizeSpeed are only hints

The specification describes optimizeSpeed and geometricPrecision as hints. The browser may use them or not, and our measurements show both outcomes:

  • geometricPrecision produced the same pixels as auto in all three engines, at device pixel ratio 1 and 2. On our test scene, zero pixels differed.
  • optimizeSpeed gave exactly the same pixels as crispEdges in Chromium and Firefox. In WebKit it gave the same pixels as auto.

So if you want hard edges, write crispEdges. Do not rely on optimizeSpeed to mean the same thing everywhere.

vector-effect: non-scaling-stroke

stroke-width is measured in the units of the viewBox. When an SVG with a viewBox of 100 units is drawn 400 pixels wide, a stroke of 4 becomes 16 pixels thick. We measured 16 in all three engines.

The same drawing at 1x and 4x. A normal stroke grows with it, a non-scaling stroke keeps its width.
The same drawing at 1x and 4x. A normal stroke grows with it, a non-scaling stroke keeps its width.

With vector-effect: non-scaling-stroke on the shape, the width is measured in screen pixels. The same line measured 4 pixels at 100 and at 400 pixels wide. This matters for charts and diagrams that scale to fit their box.

Four details we checked:

  1. It is not inherited. Set on a <g>, the shapes inside still compute none. Put it on each shape, or select them in CSS.
  2. Dashes follow it. With stroke-dasharray of 5 and 5, the dashes became 5 screen pixels long too. On a 400px line that meant 39 dashes instead of 9. More on dashes in stroke-dasharray.
  3. A CSS transform on the <svg> still thickens it. With transform: scale(4) on the <svg> element, the non-scaling line measured 16 pixels. Size the SVG with width and height instead.
  4. Only one value works. The specification also lists non-scaling-size, non-rotation and fixed-position. CSS.supports() returned false for all three in every engine we tested.

color-interpolation for gradients, color-interpolation-filters for filters

Colours can be mixed in two spaces. sRGB mixes the stored numbers directly. linearRGB first converts them to linear light, mixes, and converts back, which makes the middle of a mix brighter.

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>color-interpolation: sRGB vs linearRGB</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  h3 { font-size: 14px; margin: 0 0 6px; }
  code { font: 600 12px ui-monospace, Consolas, monospace; background: #e8ebf0; border-radius: 4px; padding: 0 3px; }
  svg { display: block; width: 100%; height: auto; background: #fff; border: 1px solid #d5d9e0; border-radius: 8px; }
  .lbl { font: 12px system-ui, sans-serif; fill: #374151; }
  section + section { margin-top: 14px; }
</style>
</head>
<body>
<section>
  <h3>Gradients: <code>color-interpolation</code></h3>
  <svg viewBox="0 0 360 150">
    <defs>
      <linearGradient id="bw"><stop offset="0" stop-color="#000"/><stop offset="1" stop-color="#fff"/></linearGradient>
      <!-- same stops, mixed in linear light -->
      <linearGradient id="bw-lin" color-interpolation="linearRGB"><stop offset="0" stop-color="#000"/><stop offset="1" stop-color="#fff"/></linearGradient>
      <linearGradient id="rb"><stop offset="0" stop-color="#f00"/><stop offset="1" stop-color="#00f"/></linearGradient>
      <linearGradient id="rb-lin" color-interpolation="linearRGB"><stop offset="0" stop-color="#f00"/><stop offset="1" stop-color="#00f"/></linearGradient>
    </defs>
    <text class="lbl" x="10" y="16">sRGB (default)</text>
    <rect x="10" y="22" width="340" height="22" fill="url(#bw)"/>
    <text class="lbl" x="10" y="60">linearRGB</text>
    <rect x="10" y="66" width="340" height="22" fill="url(#bw-lin)"/>
    <rect x="10" y="98" width="340" height="18" fill="url(#rb)"/>
    <rect x="10" y="120" width="340" height="18" fill="url(#rb-lin)"/>
    <!-- the midpoint -->
    <line x1="180" y1="18" x2="180" y2="142" stroke="#dc2626" stroke-dasharray="3 3"/>
  </svg>
</section>

<section>
  <h3>Filters: <code>color-interpolation-filters</code></h3>
  <svg viewBox="0 0 360 120">
    <defs>
      <pattern id="stripes" width="16" height="16" patternUnits="userSpaceOnUse">
        <rect width="8" height="16" fill="#e11d48"/><rect x="8" width="8" height="16" fill="#16a34a"/>
      </pattern>
      <!-- default colour space for filters is linearRGB -->
      <filter id="blur-lin"><feGaussianBlur stdDeviation="4"/></filter>
      <filter id="blur-srgb" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="4"/></filter>
    </defs>
    <text class="lbl" x="10" y="16">linearRGB (default)</text>
    <text class="lbl" x="185" y="16">sRGB</text>
    <rect x="10" y="24" width="165" height="86" fill="url(#stripes)" filter="url(#blur-lin)"/>
    <rect x="185" y="24" width="165" height="86" fill="url(#stripes)" filter="url(#blur-srgb)"/>
  </svg>
</section>
</body>
</html>
Top: the same gradients in sRGB and linearRGB. Bottom: the same blur with the two filter colour spaces.

The two properties have different defaults:

  • Gradients mix in sRGB by default. Add color-interpolation="linearRGB" to the <linearGradient> to switch. The middle of a black-to-white gradient measured 127 or 128 in sRGB and 187 or 188 in linearRGB, in all three engines.
  • Filters work in linearRGB by default. Add color-interpolation-filters="sRGB" to the <filter> to switch. A white box through a filter that halves each channel measured 188 by default and 128 with sRGB.
Middle of a black-to-white gradient, and a white box multiplied by 0.5, in each colour space.
Middle of a black-to-white gradient, and a white box multiplied by 0.5, in each colour space.

The filter needs the -filters property. Setting plain color-interpolation="sRGB" on the <filter> left the result at 188.

Both properties are inherited, so one attribute on a parent covers everything inside. The SVG filter guide shows where this matters in feColorMatrix, and SVG gradients covers stops and directions.

color-rendering and buffered-rendering

color-rendering is a hint about colour quality. We drew the same gradient, shapes and text with optimizeSpeed and with optimizeQuality and compared them with the default. No pixel changed in any of the three engines.

Chromium accepts the value through CSS.supports(). Firefox and WebKit returned false.

buffered-rendering is a hint that the content changes rarely and can be cached. It is not part of SVG 2. Chromium and WebKit accept static through CSS.supports(), Firefox does not, and no pixel changed in any engine. It is safe to delete from old files.

A finished example: a chart that stays sharp

The same two properties make a chart that stretches to any width. The viewBox is 100 by 50 units and preserveAspectRatio="none" stretches it to fill the box, so the drawing scales differently across and down.

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>A chart that stays sharp at any size</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .ctl { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; font-size: 13px; margin-bottom: 12px; }
  .ctl code { font: 600 12px ui-monospace, Consolas, monospace; }
  input[type=range] { width: 150px; }
  .box { box-sizing: border-box; background: #fff; border: 1px solid #d5d9e0; border-radius: 10px; padding: 8px; }
  #chart { display: block; width: 100%; height: 220px; }
  .grid line { stroke: #64748b; stroke-width: 1; vector-effect: non-scaling-stroke; }
  .grid.crisp { shape-rendering: crispEdges; }     /* gridlines snap to whole pixels */
  #data { fill: none; stroke: #2563eb; stroke-width: 2; stroke-linejoin: round; }
  #data.fixed { vector-effect: non-scaling-stroke; } /* width stays 2 screen px */
  #out { font: 12px ui-monospace, Consolas, monospace; color: #4b5563; margin-top: 8px; }
</style>
</head>
<body>
<div class="ctl">
  <label>Width <input id="w" type="range" min="25" max="100" value="100"></label>
  <label><input id="ns" type="checkbox" checked> <code>non-scaling-stroke</code></label>
  <label><input id="ce" type="checkbox" checked> <code>crispEdges</code> grid</label>
</div>

<div class="box" id="box">
  <!-- 100 x 50 drawing units, stretched to fill the box -->
  <svg id="chart" viewBox="0 0 100 50" preserveAspectRatio="none">
    <g class="grid crisp" id="grid">
      <line x1="0" y1="10" x2="100" y2="10"/><line x1="0" y1="20" x2="100" y2="20"/>
      <line x1="0" y1="30" x2="100" y2="30"/><line x1="0" y1="40" x2="100" y2="40"/>
    </g>
    <polyline id="data" class="fixed" points="0,40 12,33 25,36 37,22 50,26 62,14 75,18 87,9 100,12"/>
  </svg>
</div>
<div id="out"></div>

<script>
  const box = document.getElementById('box');
  const chart = document.getElementById('chart');
  const data = document.getElementById('data');
  const grid = document.getElementById('grid');
  const out = document.getElementById('out');

  function report() {
    // how many screen pixels one drawing unit takes, across and down
    const r = chart.getBoundingClientRect();
    const sx = r.width / 100, sy = r.height / 50;
    const w = data.classList.contains('fixed') ? '2 px' : (2 * sy).toFixed(1) + ' px on flat parts, ' + (2 * sx).toFixed(1) + ' px on steep parts';
    out.textContent = 'Scale: x ' + sx.toFixed(2) + ', y ' + sy.toFixed(2) + ' | line width: ' + w;
  }

  document.getElementById('w').addEventListener('input', (e) => {
    box.style.width = e.target.value + '%';
    report();
  });
  document.getElementById('ns').addEventListener('change', (e) => {
    data.classList.toggle('fixed', e.target.checked);
    report();
  });
  document.getElementById('ce').addEventListener('change', (e) => {
    grid.classList.toggle('crisp', e.target.checked);
  });

  window.addEventListener('resize', report);
  report();
</script>
</body>
</html>
Drag the width slider, then untick each option to see what it was doing.
  • Data line: non-scaling-stroke keeps it 2 pixels thick. Untick it and the stroke stretches with the drawing, so its thickness changes from one segment to the next.
  • Gridlines: each one is a 1px non-scaling stroke on a whole-pixel position, so it straddles two rows. crispEdges snaps it to one.
.grid line { vector-effect: non-scaling-stroke; }
.grid { shape-rendering: crispEdges; }
#data { vector-effect: non-scaling-stroke; }

The line chart guide builds a complete chart around the same idea.

When it does not work

What you see Cause Fix
Thin lines look grey and blurry The line sits across two pixel rows shape-rendering: crispEdges on the lines
Circles look jagged crispEdges on curved shapes Limit it to a <g> of straight lines
Lines get thicker as the SVG grows stroke-width is in viewBox units vector-effect: non-scaling-stroke
non-scaling-stroke on a <g> does nothing The property is not inherited Put it on the shapes
Line still thickens with non-scaling-stroke A CSS transform scales the <svg> Size the SVG with width and height
The number of dashes changes as the SVG resizes Dash lengths follow the non-scaling stroke Give dash lengths in screen pixels
Filter colours look washed out Filters default to linearRGB Add color-interpolation-filters="sRGB"
geometricPrecision changes nothing It is a hint, and the engines ignored it Nothing to fix, auto is the same

Rendering differences are easy to miss in a screenshot, because the image gets resized and smoothed on the way. A live page shows the real pixels on the viewer's own screen.

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 switch the values and drag the slider themselves. If you change the code later, the same link shows the new version.

Questions people ask

What does shape-rendering: crispEdges do?

It turns off anti-aliasing for the edges of SVG shapes. Every pixel is either fully inside the shape or fully outside, so a 1px line that would be drawn as two grey rows becomes one solid row. Straight lines look sharp, curves and diagonals look jagged.

Is shape-rendering: geometricPrecision better than auto?

In our tests it made no pixel difference. We compared a circle, a diagonal line, a gradient rectangle and text in Chromium, Firefox and WebKit, at device pixel ratio 1 and 2, and geometricPrecision matched auto exactly in all of them.

Why does my SVG stroke get thicker when the SVG is larger?

stroke-width is measured in the units of the viewBox, so it scales with the drawing. Add vector-effect: non-scaling-stroke to the shape and the width is measured in screen pixels instead.

Can I put vector-effect on a group?

Setting it on a <g> does not reach the shapes inside, because vector-effect is not inherited. Put it on each shape, or write a CSS rule that selects them, such as .chart path.

Does shape-rendering work on SVG text?

No. It applies to shapes such as path, line, rect and circle. Adding crispEdges to an SVG that contained text changed the shapes and left the text pixels identical in all three engines we tested.

Keep reading