csssvgcss-transformsshadow-domsvg-transforms

How to control `transform-box` for `<use>` elements?


Background

I’m loving the expanded CSS support in SVG2. It’s great not having to rewrite attributes over and over. So I’ve been converting some code in a project from SVG attributes to CSS. Most of this has worked just fine.

When it comes to transforms, things can seem tricky if you are comfy with how CSS transforms work in HTML. (This is especially true for rotate() transformations, which is the focus of this question.) That’s because SVG doesn’t have the “automatic flow” that HTML does.

In other words, when you have a bunch of HTML elements, one after another, they will automatically lay themselves out according to the box model.

There is no such “automatic” or “default” layout in SVG. As a result, SVG transforms default to being calculated from the origin. (That’s 0,0 in user coordinates).


The Almost-Perfect Solution

For most elements, there’s a simple solution: the awesome CSS property transform-box. In most cases, using the following CSS will allow you to transform SVG elements in pretty much the same way as HTML elements:

/* whatever elements you want to transform */
rect, polygon {      
    transform-box:    fill-box;  
    transform-origin: center center;  /* or `top left`, `center right`, etc. */
}

.rotate90 {
    transform: rotate(90deg);
}

Now, you can just do something like...

<rect class="rotate90" x="123" y="789" width="50" height="50" />

And it will rotate around the transform-origin specified in the CSS. Since the above example used a transform-origin of center center, it rotates in place.

That matches the behavior of HTML elements using transform: rotate(…). And—especially if there’s a lot of rotations like this in an SVG image—it is way, way better than the equivalent SVG markup.

Why is CSS better than the SVG markup equivalent?

Because SVG’s rotate() function has a slightly different syntax that does not have an equivalent to the transform-box: fill-box CSS used above, unless you specify an X and Y coordinate for every rotation.

That means you have to put in the rotation point every time, like so:

<rect x="123" y="789" width="50" height="50" 
      transform="rotate(90 123 789)"
></rect>
<!-- 
  In this example, the rotation pivots around the X and Y coordinates of the `rect` element.
  
  If you wanted to rotate around the center, you would have to calculate:
  
  x + width/2
  y + width/2

  And use those values in the `rotate()` function instead.

 -->

The Problem

The problem I’ve run into is that the CSS solution does not work with <use /> elements.

It’s pretty clear why: the <use /> element clones the referenced element into a new location. So, as far as the CSS is concerned, it is transforming the <use /> element, and not the cloned (Shadow-DOM) content.

When it comes to other challenges involving applying CSS to <use />—such as setting up different color schemes—there are solutions (like this one from SVG superhero Sara Soueidan).

But when it comes to getting around the issue of coordinates, I haven’t figured out a way around this.

Example Code

EDIT: To be more explicit about what I’m going for, here is some sample code.

.transform-tl,
.transform-tc,
.transform-tr,
.transform-cl,
.transform-cc,
.transform-cr,
.transform-bl,
.transform-bc,
.transform-br {
    transform-box: fill-box;
}

.transform-tl { transform-origin: top left; }
.transform-tc { transform-origin: top center; }
/*  
…and so on, for the other combinations of `transform-origin` keyword values… 
*/

.rotate90.cw {
    transform: rotate(90deg)
}

.rotate90.ccw {
    transform: rotate(-90deg)
}
<!-- 
    Using the CSS classes in the following manner works as intended for most SVG elements as intended.
    
    But with the `<use />` element, the rotation does not pivot around the top left corner, as expected... :(
-->
<use 
    class="transform-tl rotate90 cw" 
    x    ="0" 
    y    ="1052" 
    href ="#block-A12-2"
></use>

(Thanks to @PaulLeBeau for the nudge to include this bit of code.)


Does anyone have a solution?

(Even a workaround solution—as long as it involves less repetition than specifying the SVG transform attribute on every <use />—would be welcome!)


Solution

  • Assuming the same conditions as @Sphinxxx postulated...

    Then you can also just wrap your <use> element in a group (<g>) element and apply the rotate class to that.

    /* whatever elements you want to transform */
    rect, polygon, use, g {
        transform-box: fill-box;
        transform-origin: center center; /* or `top left`, `center right`, etc. */
    }
    
    .rotate45 {
        transform: rotate(45deg);
    }
    <svg width="300" height="200">
        <defs>
          <rect id="rr" x="80" y="60" width="50" height="50" />
        </defs>
    
        <use href="#rr" x="100" y="0" fill="tomato" />
        <g class="rotate45">
          <use href="#rr" x="100" y="0" fill="lime" />
        </g>
    </svg>

    What's going on? Why does this work?

    The reason has to do with how <use> elements are dereferenced and what happens to transforms when that happens. If you read the section about <use> elements in the SVG spec, it says:

    In the generated content, the ‘use’ will be replaced by ‘g’, where all attributes from the ‘use’ element except for ‘x’, ‘y’, ‘width’, ‘height’ and ‘xlink:href’ are transferred to the generated ‘g’ element. An additional transformation translate(x,y) is appended to the end (i.e., right-side) of the ‘transform’ attribute on the generated ‘g’, where x and y represent the values of the ‘x’ and ‘y’ attributes on the ‘use’ element.

    So, the following SVG (which I presume will be something like you were trying):

    <use href="#rr" x="100" y="0" fill="lime" class="rotate45" />
    

    will be dereferenced into the equivalent of:

    <g fill="blue" transform="rotate(45) translate(100,0)">
      <rect x="80" y="60" width="50" height="50" />
    </g>
    

    Since you can think of transforms as being applied from right to left, the translate(100,0) will be applied before the rotation, meaning that the rotation will be moving something that is offset 100 pixels away from where you want it. That's why the transform-box: fill-box is not working for <use> elements.

    rect, polygon, use, g  {
        transform-box: fill-box;
        transform-origin: center center; /* or `top left`, `center right`, etc. */
    }
    
    .rotate45 {
        transform: rotate(45deg);
    }
    <svg width="300" height="200">
        <defs>
          <rect id="rr" x="80" y="60" width="50" height="50" />
        </defs>
    
        <use href="#rr" x="100" y="0" fill="tomato" />
    
        <use href="#rr" x="100" y="0" fill="blue" stroke="blue" class="rotate45" />
    
        <g fill="lime" transform="rotate(45) translate(100,0)">
          <rect x="80" y="60" width="50" height="50" />
        </g>
    </svg>

    However with my solution, the <g> + <use> set...

    <g class="rotate45">
      <use href="#rr" x="100" y="0" fill="lime" />
    </g>
    

    will be dereferenced into the equivalent of:

    <g class="rotate45">
      <g fill="lime" transform="translate(100,0)">
        <rect x="80" y="60" width="50" height="50" />
      </g>
    </g>
    

    rect, polygon, use, g  {
        transform-box: fill-box;
        transform-origin: center center; /* or `top left`, `center right`, etc. */
    }
    
    .rotate45 {
        transform: rotate(45deg);
    }
    <svg width="300" height="200">
        <defs>
          <rect id="rr" x="80" y="60" width="50" height="50" />
        </defs>
    
        <use href="#rr" x="100" y="0" fill="tomato" />
    
        <g class="rotate45">
          <use href="#rr" x="100" y="0" fill="blue" stroke="blue"/>
        </g>
    
        <g class="rotate45">
          <g fill="lime" transform="translate(100,0)">
            <rect x="80" y="60" width="50" height="50" />
          </g>
        </g>
    </svg>

    In this version, each of the two transform operations is applied to different elements, so the transform-box and transform-origin are applied separately. And the transform origin has no effect on translations.

    So what that means is that the transform-box calculation for the rotation (ie on the <g>) will be applied to the already-translated object. So it will behave as you want.

    In the days before transform-box, these two examples would have given the same result.