transform on a root <svg> in JSX does nothing, and nothing errors

4 min read SVGReactCSSJSX

A rotation prop rendered perfectly and never rotated. transform is an SVG presentation attribute, so it only applies to elements inside an SVG document. On a root <svg> in an HTML tree, the HTML layout box owns the transform and the attribute is dropped.

TL;DR · THE FIX

<svg transform="rotate(-8)"> is silently ignored on a root <svg> in an HTML tree, because transform is an SVG presentation attribute that only applies inside an SVG document. Use style={{ transform: "rotate(-8deg)" }} instead, and note the deg unit, which the attribute form does not take. Measured by diffing rendered frames: the attribute version is pixel-identical to no rotation at all.

The symptom

I added a rot prop to a component so a few elements could sit at a slight angle. The component rendered correctly and every other prop worked. The rotation did nothing, at any value.

function Sticker({ rot = 0, children }) {
  return (
    <svg viewBox="0 0 200 120" width={200} transform={`rotate(${rot})`}>
      {children}
    </svg>
  );
}

There was no warning in the console and no React error about an unknown prop, because transform is a valid SVG attribute and React passes it straight through. Inspecting the element in devtools showed the attribute present and correct, transform="rotate(-8)", sitting in the DOM and doing nothing. There is no error to search for, and checking whether the attribute reached the DOM returns a clean yes.

What I tried first

I assumed a value problem, since SVG transform syntax has its own quirks, and tried rotate(-8), rotate(-8 100 60) with an explicit origin, and matrix(...). All present in the DOM, all inert. Then I assumed React was doing something with camelCase, since it rewrites some SVG attribute names and not others. It is not: transform is passed through verbatim, which devtools had already told me. Then I checked whether something was overriding it in CSS, because a transform: none from a reset would produce exactly this. Nothing was.

What settled it was rendering the same frame twice, once with rot={-8} and once with rot={0}, and diffing the images. They were pixel identical, so the attribute was not being applied at all, which ruled out every theory about the value.

What was happening

transform is an SVG presentation attribute, and presentation attributes only apply to elements inside an SVG document fragment. A root <svg> sitting in an HTML tree is not inside one. It is an HTML-level replaced element whose position and geometry are owned by the HTML layout box that contains it, and the SVG coordinate system starts inside it. So the presentation attribute has nothing to apply to: there is no parent SVG coordinate space to transform the element within, and the browser drops it.

The same attribute on any child element works:

<svg viewBox="0 0 200 120">
  <g transform="rotate(-8 100 60)">   {/* this rotates */}
    ...
  </g>
</svg>

Which is why this bug survives review. Everyone has seen transform work on an SVG element, because everyone has used it on a <g> or a <rect>. The attribute is valid and correctly spelled; it is one level too far out, and the outermost level is the one that fails. The browser has no reason to complain, since an unapplied presentation attribute is just an attribute.

The fix

Use the CSS property, which the HTML layout box does respect:

function Sticker({ rot = 0, children }) {
  return (
    <svg
      viewBox="0 0 200 120"
      width={200}
      style={{ transform: `rotate(${rot}deg)` }}
    >
      {children}
    </svg>
  );
}

Two things to get right in that one line. The deg unit is mandatory: the CSS transform property requires a unit on the angle, and the SVG attribute form does not take one at all. So rotate(-8) is correct as an attribute and invalid as CSS, and CSS discards the whole declaration when it cannot parse the value. Move the string across without adding deg and you swap a silently ignored attribute for a silently discarded style rule, which looks like exactly the same bug and will convince you the CSS approach does not work either.

And set a transform-origin if the default is wrong for you. CSS rotates around the element’s centre by default, while the SVG attribute rotates around the origin of the coordinate system unless you pass explicit centre coordinates. If you are porting an existing rotate(a cx cy) you will need transform-origin to match it, and the two will not look the same until you do.

If you need the attribute form, wrap the contents in a <g> and transform that instead. It keeps everything inside the SVG coordinate system, where presentation attributes live.

The lesson

A valid attribute in an invalid position produces silence. Every debugging habit that serves you well elsewhere, checking the console, confirming the attribute reached the DOM, verifying the value syntax, comes back clean, because none of them ask whether this element is the kind of element the attribute applies to.

For SVG-in-HTML specifically, the root <svg> is an HTML element that happens to contain an SVG document. Anything positional about the element itself is CSS and HTML layout; anything positional about its contents is SVG. Most “my SVG ignores me” bugs come from confusion about which side of that boundary you are on, and the boundary is invisible because the tag name is the same on both sides of it.

When an attribute is present in the DOM and having no effect, diff two rendered frames before theorising about the value. Pixel-identical output against a control tells you the property was never applied, which is a different investigation from “it was applied and computed wrong”.

Related fixes

Discussion

Powered by GitHub. Sign in to leave a comment.