Steve
Steve

Reputation: 3663

Can you control how an SVG's stroke-width is drawn?

Currently building a browser-based SVG application. Within this app, various shapes can be styled and positioned by the user, including rectangles.

When I apply a stroke-width to an SVG rect element of say 1px, the stroke is applied to the rect’s offset and inset in different ways by different browsers. This is proving to be troublesome, especially when I try to calculate the outer width and visual position of a rectangle and position it next to other elements.

For example:

My only solution so far would be to draw the actual borders myself (probably with the path tool) and position the borders behind the stroked element. But this solution is an unpleasant workaround, and I’d prefer not to go down this road if possible.

So my question is, can you control how an SVG’s stroke-width is drawn on elements?

Upvotes: 293

Views: 197981

Answers (14)

herrstrietzel
herrstrietzel

Reputation: 17325

Update 2023: The current draft renamed the attribute to stroke-align

Browser Support 2023:

See caniuse

This CSS property is not supported in any modern browser, nor are there any known plans to support it.

Polyfill-like helper function

Based on the previous approaches to combine paint-order, mask and clip-path.
(As suggested by @Xavier Ho @Jorg Janke)

This helper/polyfill actually replicates the behavior from applications like Adobe Illustrator.

function emulateStrokeAlign() {
  // if natively supported quit
  let supportsSvgStrokeAlign = CSS.supports("stroke-align", "outer");
  if (supportsSvgStrokeAlign) return false;

  let ns = "http://www.w3.org/2000/svg";
  let strokeAlignmentEls = document.querySelectorAll("*[stroke-align]");

  for (let s = 0, len = strokeAlignmentEls.length; len && s < len; s++) {
    let el = strokeAlignmentEls[s];
    let strokeAlignment = el.getAttribute("stroke-align");

    // create unique ID
    let maskId = `emulateStrokeAlignMask-${s}`;
    let clipId = `emulateStrokeAlignClip-${s}`;
    let fillCloneID = `emulateStrokeAlignFill-${s}`;
    let hasClip = document.getElementById(clipId);
    let hasMask = document.getElementById(maskId);
    let hasFillClone = document.getElementById(fillCloneID);
    
    let strokeWidthData = parseFloat(el.dataset.strokeWidth);
    let fillData = el.dataset.fill;
    
   //reset clip or mask 
    el.removeAttribute("mask");
    el.removeAttribute("clip-path");
    

    // stroke align center - quit
    if (strokeAlignment === "center" && !hasClip && !hasMask) continue;
    
    // get stroke properties
    let style = window.getComputedStyle(el);
    let strokeWidth = strokeWidthData ? strokeWidthData :  parseFloat(style.strokeWidth);
    let fill = fillData ? fillData : style.fill;
    
    
    if(strokeAlignment === "center"){
      el.removeAttribute('fill')
      el.removeAttribute('stroke')
      el.style.strokeWidth = strokeWidth
      el.style.fill = fill
      continue
    }
    
    
    
    let svg = el.closest("svg");

    // if path is not closed - quit
    let type = el.nodeName.toLowerCase();
    let isPath = type === "path";
    let d = el.getAttribute("d");
    let isClosed = d?.match(/z/gi)?.length >= 0 ? true : !isPath ? true : false;

    // open path - quit
    if (!isClosed) continue;

    //create <defs> if not previously appended
    let defs = svg.querySelector("defs");
    if (!defs) {
      defs = document.createElementNS(ns, "defs");
      svg.insertBefore(defs, svg.children[0]);
    }


    // if element has fill - clone element
    let hasFill = style.fill !== "none";
    if (!hasFillClone && hasFill) {
      let cloneFill = el.cloneNode(true);
      cloneFill.removeAttribute("stroke");
      cloneFill.removeAttribute("stroke-align");
      cloneFill.style.stroke = "none";
      cloneFill.id = fillCloneID;
      el.parentNode.insertBefore(cloneFill, el);
    }

    // create clone for mask or clip path
    let cloneMaskElID = `emulateStrokeAlignMaskClone-${s}`;
    let cloneMaskEl = document.getElementById(cloneMaskElID);
    if (!cloneMaskEl) {
      cloneMaskEl = el.cloneNode(true);
      cloneMaskEl.id = cloneMaskElID;
      cloneMaskEl.removeAttribute("stroke-align");
      cloneMaskEl.setAttribute("fill-rule", "evenodd");
      cloneMaskEl.removeAttribute("fill");
      cloneMaskEl.removeAttribute("stroke-width");
      cloneMaskEl.removeAttribute("stroke");
      cloneMaskEl.style.stroke = '#fff'
      cloneMaskEl.style.strokeWidth = strokeWidth * 2+'px';
      cloneMaskEl.style.fill = '#000';
      cloneMaskEl.style.paintOrder = "stroke";

    }

    let cloneCliplID = `emulateStrokeAlignClipClone-${s}`;
    let cloneClipEl = document.getElementById(cloneCliplID);
    if (!cloneClipEl) {
      cloneClipEl = el.cloneNode(true);
      cloneClipEl.id = cloneCliplID;
      cloneClipEl.removeAttribute("stroke-align");
      cloneClipEl.removeAttribute("stroke-width");
      cloneClipEl.removeAttribute("fill");
      cloneClipEl.removeAttribute("stroke");
      cloneClipEl.style.clipRule="evenodd";
      cloneClipEl.style.removeProperty("stroke");
      cloneClipEl.style.removeProperty("fill");
      cloneClipEl.style.removeProperty("stroke-width");

    }
    

    if (strokeAlignment === "outer") {
      // create mask if not previously added
      let maskEl = document.getElementById(maskId);

      if (!maskEl) {
        maskEl = document.createElementNS(ns, "mask");
        maskEl.id = maskId;
        maskEl.setAttribute("maskUnits", "userSpaceOnUse");
        maskEl.append(cloneMaskEl);
        defs.append(maskEl);
      } 
      el.setAttribute("mask", `url(#${maskId})`);
    }

    if (strokeAlignment === "inner") {
      //create clipPath
      let clipEl = document.getElementById(clipId);
      //console.log(clipId, clipEl)
      if (!clipEl) {
        clipEl = document.createElementNS(ns, "clipPath");
        clipEl.id = clipId;
        clipEl.append(cloneClipEl);
        defs.append(clipEl);
      }

      el.setAttribute("clip-path", `url(#${clipId})`);
    }

    el.style.strokeWidth = strokeWidth * 2+'px';
    /**
     * save stroke width in data attribute
     * for toggling
     */
    if (!strokeWidthData) el.dataset.strokeWidth = strokeWidth;
    if (!fillData) el.dataset.fill = fill;
  }
}
body{
  font-family: sans-serif;
}

svg {
  display: block;
  outline: 1px solid #ccc;
  overflow: visible;
}

svg>text,
svg>circle,
svg>path{
  fill:#ccc;
  stroke: red;
  stroke-opacity:1;
}

label{
  user-select:none;
}
<fieldset>
  <legend>Stroke-align</legend>
  <label ><input class="inputAlign" type="radio" name="strokeAlign" value="center" checked>center (default)</label>
  <label ><input class="inputAlign" type="radio" name="strokeAlign" value="inner" >inner</label>
  <label ><input class="inputAlign" type="radio" name="strokeAlign" value="outer" >outer</label>
</fieldset>

<svg viewBox="0 0 2400 320">
  
  <!-- open path - do nothing! -->
  <path d="M 10 150
c 50 -50 100 -50 150 0
s 100 50 150 0" stroke-width="16" stroke-align="center"/>
  
  <!-- stroked path - open - do nothing!  -->
  <path
d="M 360 10
l 300 150
l -300 150" stroke-dasharray="50 50" stroke-width="16" stroke-align="center" />
  
  
  <!-- loop path -->
  <path 
d="M 850 20
c -200 0 29.9 398.2 129.9 225
s -359.8 -173.2 -259.8 0
s 329.9 -225 129.9 -225
z" stroke-width="16" stroke-align="center" fill-rule="evenodd"/>
  
  <!-- compound path -->
  <path
d="M 1200 10
a 150 150 0 110 300
a 150 150 0 110 -300
z
m 0 100
a 50 50 0 100 100
a 50 50 0 100 -100
z
"  stroke-width="16" stroke-align="center" fill="#ccc" />
  
  
  <!-- compound path with self intersecting inner -->
  <path
d="M 1600 10
a 150 150 0 110 300
a 150 150 0 110 -300
z
M 1605 70
c -120 0 17.94 238.92 77.94 135
s -215.88 -103.92 -155.88 0
s 197.94 -135 77.94 -135
z
"  stroke-width="16" stroke-align="center" fill="#ccc" />
  
  
  <circle cx="1950" cy="160" r="140" stroke-width="16" stroke-align="center"/>
  
  <text x="2150" y="290" font-size="320" font-weight="700" stroke-width="16" stroke-align="center">8</text>
  
</svg>


<script>
// sample UI
window.addEventListener('DOMContentLoaded', e=>{

let inputAlign = document.querySelectorAll(".inputAlign");
let strokeAlignmentEls = document.querySelectorAll("*[stroke-align]");

inputAlign.forEach((inp) => {
  inp.addEventListener("input", (e) => {
    let align = document.querySelector(".inputAlign:checked").value;

    //change alignment
    setStrokeAlign(strokeAlignmentEls, align);

    // emulate
    emulateStrokeAlign();
  });
});

function setStrokeAlign(els, align) {
  els.forEach((el) => {
    el.setAttribute("stroke-align", align);
  });
}

})
</script>

Hardcoded offset via paper.js offset glenzli's plugin

This approach will actually grow/shrink your <path> elements to get the desired stroke position (using the default middle stroke-alignment).

const canvas = document.createElement("canvas");
canvas.style.display='none';
document.body.appendChild(canvas);
//const canvas = document.querySelector("canvas");
paper.setup(canvas);

let strokeEls = document.querySelectorAll("*[stroke-alignment]");
strokeEls.forEach((el,i) => {
  let type = el.nodeName;
  let style = window.getComputedStyle(el);
  let strokeWidth = parseFloat(style.strokeWidth);
  let strokeAlignment = el.getAttribute('stroke-alignment');
  let offset = strokeAlignment==='outer' ? strokeWidth/2 : (strokeAlignment==='inner' ? strokeWidth / -2 : 0); 
  // convert primitive
  if(type!=='path'){
    el = convertPrimitiveToPath(el);
  }
  let d = el.getAttribute("d");
  let polyPath = new paper.Path(el.getAttribute("d"));
  let dOffset = offset ? PaperOffset.offset(polyPath, offset)
    .exportSVG()
    .getAttribute("d") : d;
  el.setAttribute("d", dOffset);
});
body{
  margin:2em;
}

svg{
  width:100%;
  overflow:visible;
  border:1px solid #ccc;
}
<svg viewBox="0 0 12 6" xmlns="http://www.w3.org/2000/svg" stroke-width="0.5">
  <path d="M1,5 a2,2 0,0,0 2,-3 a3,3 0 0 1 2,3.5" stroke="black" fill="none" stroke-linejoin="miter"/>
  <path d="M1,5 a2,2 0,0,0 2,-3 a3,3 0 0 1 2,3.5" fill="none" stroke-linejoin="miter" stroke-alignment="outer" stroke="red" stroke-opacity="0.5" />
  <path d="M7,5 a2,2 0,0,0 2,-3 a3,3 0 0 1 2,3.5" stroke="black" fill="none" stroke-linejoin="round" />
  <path d="M7,5 a2,2 0,0,0 2,-3 a3,3 0 0 1 2,3.5" fill="none" stroke-linejoin="round" stroke-alignment="inner" stroke="red" stroke-opacity="0.5" />
</svg>

<script src="https://unpkg.com/[email protected]/dist/paper-full.min.js"></script>
<script src="https://unpkg.com/[email protected]/dist/paperjs-offset.js"></script>

However, the library struggles with complex shapes.

What's quite confusing in the SVG drafts docs are the issue annotations about several "edge cases" like open or self-intersecting paths. In fact the SVG feature should adapt the common behavior devs/designers already know from applications like Adobe Illustrator, Affinity Designer, Figma etc.

I also posted a comment in the SVGWG github repo. "‘stroke-align’ property issues: why not adapt graphic apps behavior?"

Upvotes: 6

ElBrm
ElBrm

Reputation: 344

I've read the answers in this topic since I was looking for a solution myself. In my case I couldn't edit the SVG inline so I needed to draw the stroke with external CSS. Doing this will make the stroke not fully visible because it's drawn on the outside of the path. The path becomes bigger than the viewbox so it will be hidden.

A simple fix for this is to put overflow: visible; on the svg itself. You can add a padding with half the size as the stroke-width to make it the original size.

svg {
  width: 80px;
  height: 80px;
  
  fill: transparent;
  
  & > * {
    stroke: black;
    stroke-width: 10px;
  }
}

svg.stroke-visible {
  overflow: visible;
  padding: 5px; //Half the stroke-width
}
Stroke not fully visible:
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="800" height="800" version="1.0" viewBox="0 0 64 64">
<path d="M62.799 23.737a3.941 3.941 0 0 0-3.139-2.642l-16.969-2.593-7.622-16.237a3.938 3.938 0 0 0-7.13 0l-7.623 16.238-16.969 2.593a3.937 3.937 0 0 0-2.222 6.642l12.392 12.707-2.935 17.977a3.94 3.94 0 0 0 5.797 4.082l15.126-8.365 15.126 8.365a3.94 3.94 0 0 0 5.796-4.082l-2.935-17.977 12.393-12.707a3.942 3.942 0 0 0 .914-4.001z"/>
 </svg>
 
 Stroke fully visible:
 
 <svg class="stroke-visible" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="800" height="800" version="1.0" viewBox="0 0 64 64">
<path d="M62.799 23.737a3.941 3.941 0 0 0-3.139-2.642l-16.969-2.593-7.622-16.237a3.938 3.938 0 0 0-7.13 0l-7.623 16.238-16.969 2.593a3.937 3.937 0 0 0-2.222 6.642l12.392 12.707-2.935 17.977a3.94 3.94 0 0 0 5.797 4.082l15.126-8.365 15.126 8.365a3.94 3.94 0 0 0 5.796-4.082l-2.935-17.977 12.393-12.707a3.942 3.942 0 0 0 .914-4.001z"/>
 </svg>

Upvotes: 0

lolagt99
lolagt99

Reputation: 9

This worked for me:

.btn {
 border: 1px solid black;
 box-shadow: inset 0 0 0 1px black;
}

Upvotes: 0

Yoki Yu
Yoki Yu

Reputation: 57

The easiest way I found is to add clip-path into circle

Add clip-path="circle()"

<circle id="circle" clip-path="circle()" cx="100" cy="100" r="100" fill="none" stroke="currentColor" stroke-width="5" />

Then the stroke-width="5" will magically become inner 5px stroke with absolute 100px radius.

Upvotes: 0

mnsth
mnsth

Reputation: 2287

UPDATE: The stroke-alignment attribute was on April 1st, 2015 moved to a completely new spec called SVG Strokes.

As of the SVG 2.0 Editor’s Draft of February 26th, 2015 (and possibly since February 13th), the stroke-alignment property is present with the values inner, center (default) and outer.

It seems to work the same way as the stroke-location property proposed by @Phrogz and the later stroke-position suggestion. This property has been planned since at least 2011, but apart from an annotation that said

SVG 2 shall include a way to specify stroke position

, it has never been detailed in the spec as it was deferred - until now, it seems.

No browser support this property, or, as far as I know, any of the new SVG 2 features, yet, but hopefully they will soon as the spec matures. This has been a property I personally have been urging to have, and I'm really happy that it's finally there in the spec.

There seems to be some issues as to how to the property should behave on open paths as well as loops. These issues will, most probably, prolong implementations across browsers. However, I will update this answer with new information as browsers begin to support this property.

Upvotes: 79

hirunatan
hirunatan

Reputation: 101

The solution from Xavier Ho of doubling the width of the stroke and changing the paint-order is brilliant, although only works if the fill is a solid color, with no transparency.

I have developed other approach, more complicated but works for any fill. It also works in ellipses or paths (with the later there are some corner cases with strange behaviour, for example open paths that crosses theirselves, but not much).

The trick is to display the shape in two layers. One without stroke (only fill), and another one only with stroke at double width (transparent fill) and passed through a mask that shows the whole shape, but hides the original shape without stroke.

  <svg width="240" height="240" viewBox="0 0 1024 1024">
  <defs>
    <path id="ld" d="M256,0 L0,512 L384,512 L128,1024 L1024,384 L640,384 L896,0 L256,0 Z"/>
    <mask id="mask">
      <use xlink:href="#ld" stroke="#FFFFFF" stroke-width="160" fill="#FFFFFF"/>
      <use xlink:href="#ld" fill="#000000"/>
    </mask>
  </defs>
  <g>
    <use xlink:href="#ld" fill="#00D2B8"/>
    <use xlink:href="#ld" stroke="#0081C6" stroke-width="160" fill="red" mask="url(#mask)"/>
  </g>
  </svg>

Upvotes: 0

Jorg Janke
Jorg Janke

Reputation: 1107

I found an easy way, which has a few restrictions, but worked for me:

  • define the shape in defs
  • define a clip path referencing the shape
  • use it and double the stroke with as the outside is clipped

Here a working example:

<svg width="240" height="240" viewBox="0 0 1024 1024">
<defs>
	<path id="ld" d="M256,0 L0,512 L384,512 L128,1024 L1024,384 L640,384 L896,0 L256,0 Z"/>
	<clipPath id="clip">
		<use xlink:href="#ld"/>
	</clipPath>
</defs>
<g>
	<use xlink:href="#ld" stroke="#0081C6" stroke-width="160" fill="#00D2B8" clip-path="url(#clip)"/>
</g>
</svg>

Upvotes: 85

Xavier Ho
Xavier Ho

Reputation: 17913

You can use CSS to style the order of stroke and fills. That is, stroke first and then fill second, and get the desired effect.

MDN on paint-order: https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/paint-order

CSS code:

paint-order: stroke;

Upvotes: 78

The Witness
The Witness

Reputation: 920

I don’t know how helpful will that be but in my case I just created another circle with border only and placed it “inside” the other shape.

Upvotes: 2

Phrogz
Phrogz

Reputation: 303450

No, you cannot specify whether the stroke is drawn inside or outside an element. I made a proposal to the SVG working group for this functionality in 2003, but it received no support (or discussion).

SVG proposed stroke-location example, from phrogz.net/SVG/stroke-location.svg

As I noted in the proposal,

  • you can achieve the same visual result as "inside" by doubling your stroke width and then using a clipping path to clip the object to itself, and
  • you can achieve the same visual result as 'outside' by doubling the stroke width and then overlaying a no-stroke copy of the object on top of itself.

Edit: This answer may be wrong in the future. It should be possible to achieve these results using SVG Vector Effects, by combining veStrokePath with veIntersect (for 'inside') or with veExclude (for 'outside). However, Vector Effects are still a working draft module with no implementations that I can yet find.

Edit 2: The SVG 2 draft specification includes a stroke-alignment property (with center|inside|outside possible values). This property may make it into UAs eventually.

Edit 3: Amusingly and dissapointingly, the SVG working group has removed stroke-alignment from SVG 2. You can see some of the concerns described after the prose here.

Upvotes: 516

F Lekschas
F Lekschas

Reputation: 12810

Here is a work around for inner bordered rect using symbol and use.

Example: https://jsbin.com/yopemiwame/edit?html,output

SVG:

<svg>
  <symbol id="inner-border-rect">
    <rect class="inner-border" width="100%" height="100%" style="fill:rgb(0,255,255);stroke-width:10;stroke:rgb(0,0,0)">
  </symbol>
  ...
  <use xlink:href="#inner-border-rect" x="?" y="?" width="?" height="?">
</svg>

Note: Make sure to replace the ? in use with real values.

Background: The reason why this works is because symbol establishes a new viewport by replacing symbol with svg and creating an element in the shadow DOM. This svg of the shadow DOM is then linked into your current SVG element. Note that svgs can be nested and every svg creates a new viewport, which clips everything that overlaps, including the overlapping border. For a much more detailed overview of whats going on read this fantastic article by Sara Soueidan.

Upvotes: 4

max-lt
max-lt

Reputation: 1441

A (dirty) possible solution is by using patterns,

here is an example with an inside stroked triangle :

https://jsfiddle.net/qr3p7php/5/

<style>
#triangle1{
  fill: #0F0;
  fill-opacity: 0.3;
  stroke: #000;
  stroke-opacity: 0.5;
  stroke-width: 20;
}
#triangle2{
  stroke: #f00;
  stroke-opacity: 1;
  stroke-width: 1;
}    
</style>

<svg height="210" width="400" >
    <pattern id="fagl" patternUnits="objectBoundingBox" width="2" height="1" x="-50%">
        <path id="triangle1" d="M150 0 L75 200 L225 200 Z">
    </pattern>    
    <path id="triangle2" d="M150 0 L75 200 L225 200 Z" fill="url(#fagl)"/>
</svg>

Upvotes: 1

user1574945
user1574945

Reputation: 71

As people above have noted you'll either have to recalculate an offset to the stroke's path coordinates or double its width and then mask one side or the other, because not only does SVG not natively support Illustrator's stroke alignment, but PostScript doesn't either.

The specification for strokes in Adobe's PostScript Manual 2nd edition states: "4.5.1 Stroking: The stroke operator draws a line of some thickness along the current path. For each straight or curved segment in the path, stroke draws a line that is centered on the segment with sides parallel to the segment." (emphasis theirs)

The rest of the specification has no attributes for offsetting the line's position. When Illustrator lets you align inside or outside, it's recalculating the actual path's offset (because it's still computationally cheaper than overprinting then masking). The path coordinates in the .ai document are reference, not what gets rastered or exported to a final format.

Because Inkscape's native format is spec SVG, it can't offer a feature the spec lacks.

Upvotes: 6

Steve
Steve

Reputation: 3663

Here's a function that will calculate how many pixels you need to add - using the given stroke - to the top, right, bottom and left, all based on the browser:

var getStrokeOffsets = function(stroke){

        var strokeFloor =       Math.floor(stroke / 2),                                                                 // max offset
            strokeCeil =        Math.ceil(stroke / 2);                                                                  // min offset

        if($.browser.mozilla){                                                                                          // Mozilla offsets

            return {
                bottom:     strokeFloor,
                left:       strokeFloor,
                top:        strokeCeil,
                right:      strokeCeil
            };

        }else if($.browser.webkit){                                                                                     // WebKit offsets

            return {
                bottom:     strokeCeil,
                left:       strokeFloor,
                top:        strokeFloor,
                right:      strokeCeil
            };

        }else{                                                                                                          // default offsets

            return {
                bottom:     strokeCeil,
                left:       strokeCeil,
                top:        strokeCeil,
                right:      strokeCeil
            };

        }

    };

Upvotes: 8

Related Questions