skip to Main Content

I want to build an icon set with SVG and use it for buttons. These icons change their styles based on CSS classes applied. E.g. imagine a burger menu icon which transforms to a close icon when the menu is opened.

I’ve simplified the paths in all examples here for better readability. Please just imagine that those 2 paths would expand to finally show a cross, animated with CSS transitions. The expected result for the code here looks like this:

Paths expanding with stroke-dasharray

Use icons from .svg file

Of course I’d prefer using an external sprite SVG for caching like

<!-- icons-file.svg -->
<svg>
  <def>
    <symbol id="menu" viewBox="0 0 100 100">
      <path d="M20,40H90"/>
      <path d="M20,60H70"/>
    </symbol>
    <!-- more symbols to come -->
    <style>
      #menu > path {
        /* ... */
        stroke-dasharray: 40 500;
      }
      .active #menu > path:nth-child(1) {
        stroke-dasharray: 80 500;
      }
      .active #menu > path:nth-child(2) {
        stroke-dasharray: 50 500;
        stroke-dashoffset: -10;
      }
    </style>
  </def>
</svg>

and use it in my HTML with

<a href="#"><svg><use href="icons-file.svg#menu"/></svg></a>

This won’t even work halfway because <style/> tags are ignored by <use/>.

Add the sprite SVG inline

Thankfully I’m working on a single page PWA. Of course cashing is better, but with having the SVG inline I (hopefully) only add to the initial loading of the app, not every page/view.

<use/> restrictions still remain, but here I can define my SVG styles in the HTML layer:

<html>
  <!-- ... -->
  <body>
    <svg>
      <def>
        <symbol id="menu" viewBox="0 0 100 100"><!-- ... --></symbol>
        <!-- more symbols to come -->
      </def>
    </svg>
    <style>
    #menu > path {
      /* ... */
      stroke-dasharray: 40 500;
    }
    .active #menu > path:nth-child(1) {
      stroke-dasharray: 80 500;
    }
    /* ... */
    </style>

    <a href="#"><svg><use href="#menu"/></svg></a>
  </body>
</html>

Now, the icon is displayed correctly in it’s default state (as a burger menu icon). But no matter where I apply the active CSS class, even on the <use/> node, it will never be respected. As far as I understand this is because CSS selectors can’t break the borders of shadow DOM.

But wait, what about the :host selector? Having a rule like the following should do the trick:

:host(.active) #menu > path:nth-child(1) {
  stroke-dasharray: 80 500;
}
/* or maybe */
:host(.active) path:nth-child(1) {
  stroke-dasharray: 80 500;
}

Again, no luck. See an example on codepen. To be honest, I don’t know the reason. I guess it’s because the shadow DOM of <use/> is closed, but could not find any information if that’s really the reason. This example on jsfiddle with a custom element and open shadow DOM works perfectly.

The CSS property: inherit trick

One might say I should do something like this:

path {
  stroke-dasharray: inherit;
}

Then I could set stroke-dasharray to any value outside, e.g.

<a style="stroke-dasharray: 80 500"><svg><!-- ... --></svg></a>

Yeah, that were possible if I’d only have one path or all paths had the same length, starting and ending style. That’s not the case.

Whole SVG for each icon and every instance

Finally, my only way out right now is to have the icon’s SVG directly in the anchor:

<a href="#">
  <svg id="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
    <path d="M20,40H80V20"/>
    <path d="M20,60H70V70"/>
    <style>
      path {
        fill: none;
        stroke: black;
        stroke-width: 6px;
        transition: all 0.5s;
      }
      path:nth-child(1) {
        stroke-dasharray: 40 500;
      }
      .active path:nth-child(1) {
        stroke-dasharray: 80 500;
      }
      path:nth-child(2) {
        stroke-dasharray: 10 500;
      }
      .active path:nth-child(2) {
        stroke-dasharray: 50 500;
        stroke-dashoffset: -10;
      }
    </style>
  </svg>
</a>

Here is the result on codepen

This is the worst case. No caching and for icons I use in lists, I blow the result heavily by having the same code dozens of times.

So my question is: Do I oversee something? Is there a better way?

2

Answers


  1. You cannot write selectors that cross into a shadow DOM, but you can inherit property values, among them custom variables. And that is the trick: in the place where you use the icon, you set a variable to a certain value. And inside the icon template, you assign the value of this variable to a property.

    a svg {
      width: 50px;
      height: 50px;
    }
    
    use[href="#menu"] {
      --state: 1;
    }
    a:hover use[href="#menu"] {
      --state: 2;
    }
    
    #menu path {
      fill: none;
      stroke:black;
      stroke-dasharray: calc(40px * var(--state)), 500px;
      transition: stroke-dasharray .2s;
    }
        <svg width="0" height="0">
          <symbol id="menu" viewBox="0 0 100 100">
            <path d="M20,40H90"/>
            <path d="M20,60H70"/>
          </symbol>
        </svg>
    
        <a href="#"><svg><use href="#menu"/></svg></a>
        <a href="#"><svg><use href="#menu"/></svg></a>
    Login or Signup to reply.
  2. For icons, <use> and <symbol> have had its use…

    It can be done much more semantic, with native Web Components, supported in all browsers:

    • Create a <template is="ICON-NAME"> for every SVG icon, including all its [state] CSS/animation styles

    • You can design each SVG in a CodePen and copy it whole into the <template>

    • Define a native Web Component <svg-icon>
      that clones the <template> in shadowDOM. So all CSS from the template is scoped!

    <template id="ICON-LINUS">
      <svg viewBox="0 0 100 100" >
        <path id="P1" d="M20 40H80V20" />
        <path id="P2" d="M20 60H70V70" />
        <style>
          path { fill: none; stroke: black; stroke-width: 8px;transition: all 0.5s }
          #P1 {  stroke:blue;   stroke-dasharray: 40 500 }
          :host([active]) #P1 { stroke-dasharray: 80 500 }
          #P2 { stroke:red;     stroke-dasharray: 10 500 }
          :host([active]) #P2 { stroke-dasharray: 50 500; stroke-dashoffset: -10 }
          :host([disabled]) { pointer-events:none }
        </style>
      </svg>
    </template>
    
    <style>
      svg-icon { cursor:pointer;display:inline-block;width:140px;background:grey }
      svg-icon[active]{ background:green }
    </style>
    
    <svg-icon is="LINUS"         ></svg-icon>
    <svg-icon is="LINUS" active  ></svg-icon>
    <svg-icon is="LINUS"         ></svg-icon>
    <svg-icon is="LINUS" disabled></svg-icon>
    
    <script>
      customElements.define("svg-icon", class extends HTMLElement {
        connectedCallback() {
          this.attachShadow({mode:"open"})
               .append(document.getElementById("ICON-"+this.getAttribute("is"))
                               .content.cloneNode(true));
          this.onclick =
                () => this.toggleAttribute("active",!this.hasAttribute("active"));
        }
      });
    </script>
    • GZIP and Brotli love repetitions, so put all templates in the <head>
      you already said you are working on a PWA, so indeed no need to cache anything; if not, use my load-file Web Component to load external HTML Templates.
      Minor detail; I removed the , (comma) from the d-paths, since that is a hardly used character in HTML. A more frequently used space character will compress better.

    • Make sure you use <svg-icon> in the DOM after the <template> are defined (or add async load logic)

    • Attaching the shadowDOM in the connectedCallback (usually done in the constructor) because we need DOM information: the is attribute from svg-icon

    • (not 100% sure) I think your :host experiment did not work because you tried to apply it to a user-agent (==Browser!) shadowRoot created by <use> (like <input> and <textarea> also create). They are more like locked down IFrames.
      Those are a different shadowRoots than the open (or hardly used closed) shadowRoots us mortals in Userland (not user-agent!) create with Web Components.

      • I do know Web Components only work in the HTML NameSpace, not in the SVG NameSpace.
      • Also note Apple will never implement Customized Built-In Elements, so we need to stick to extends HTMLElement (or not support Safari)
    • You probably want to extend the onclick function to emit a CustomEvent bubbling up through shadowDOM (with composed:true) emitting the iconname your PWA can respond to.

    • https://yqnn.github.io/svg-path-editor/# is great for editing single d-paths, converting them to relative notation.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search