March 28th, 2024
Sizing SVGs in CSS with SVGR
It feels like I've been struggling to correctly size SVGs for years. That's not to say I can't do it as I seem to always ship my designs eventually. However, at least once a year, I find myself struggling to size some SVG that doesn't "just work" as I'd expect. I never understand quite what I'm supposed to do.
Today I encountered yet another stubborn SVG as I implement a new design of the Juicy home screen for signed-in users. I added my SVG to the title bar and it was the wrong height by default. As shown in the screenshot below, the logo defaults to a height of 36px. My design needs it at 32px. Resizing the underlying asset isn't an option because it is used in other parts of the code and I'm allergic to duplication.

The Juicy frontend is styled entirely with CSS. To this end, I want to avoid explicitly setting width and height attributes on the SVG element itself. My first intuition is to add a style of `height: 32px`. That should work, right?

Well, that did set the height to 32px. Unfortunately it's clipped the content instead of scaling it. This will not do. What I need to figure out is how to scale the image vertically while also preserving the aspect ratio.
Our bundling stack
Let's talk about how Juicy is built before continuing this journey. As with any complex project, Juicy's frontend code is processed by a multi-step compilation pipeline, transforming the code that's written into the final rendered product. The specifics of this pipeline are relevant insofar as any solutions (and problems) documented here will be unique to Juicy's specific pipeline.
Juicy is compiled with Webpack and SVGs are handled by SVGR (via the @svgr/webpack plugin). This setup allows us to import SVG files directly into code as if they were React components.

The height change shown above was accomplished by adding CSS styling from an imported Sass module.

And the style itself is exactly what you'd expect.

As a developer, this sugar is very nice. However, it presents a small obstacle right now as it obfuscates what exactly is rendered.
The missing viewBox attribute
Taking a look at the rendered HTML, I see the following. (The first <svg> tag is the menu icon, so ignore that. The second <svg> tag (highlighted) is the Logo.)

It looks like SVGR is adding width and height properties, which I'm then overriding with a CSS class. I'm more interested, however, in what's missing. There's no `viewBox` property.
According to this MDN page, I probably want to set the `preserveAspectRatio` attribute on this svg. However, that depends on `viewBox` being set. (Update: this is technically incorrect as I figure out later; however, the viewBox ends up being important regardless so I've opted to include the next section as I originally recorded it.)
Generating a viewBox attribute
A bit of digging leads me to a note in the SVGR docs that mentions another transformer, SVGO, has a plugin that will add `viewBox` whenever it is missing. Ironically, this note is under a different option with different behaviour.
"Removal [of the width and height attributes] is guaranteed if `dimensions: false` [is set], unlike the `removeDimensions: true` SVGO plugin option which also generates a viewBox from the dimensions if no viewBox is present."
From this, it sounds like SVGO has a plugin which will always add a viewBox attribute. Perhaps that would be helpful here. At this point, I also learn that SVGO is included by SVGR and enabled by default.
But then a couple lines down I notice this point:
"When removing dimensions, SVGO will be configured not to remove the viewBox if one is present. You can override this behaviour via your own config."
This is an odd statement because I don't see anything in the docs that says viewBox will be removed. However, this line certainly implies that's the case. Let's see what happens when I disable dimensions on SVGR.

Let's inspect the resulting HTML.

Success! Okay, we've found our viewBox by disabling dimensions in SVGR.
And now my logo looks rather silly. But at least it's not clipped.

Discovering the importance of explicit dimensions
Upon closer inspection, I realize I have a few new problems. The first is that this configuration change impacted every image in the app. While the Juicy icon looks very small in the screenshot above (and it is), that's amplified by the fact that my menu icon is now very big. The menu icon previously rendered at 18px and now is just short of 32px.

Now I have two problems to chase and need to decide my priorities somewhat carefully. Do I fix all the now-broken SVGs throughout the app or do I try to get the logo to the correct size first?
Fixing all the SVGs in the app would take a bit of time and I decide this would be premature. What if this direction doesn't end up working out? After all, the purpose of this expedition is to get the Juicy logo sized correctly and I haven't done that yet. While current results look promising, there's always a risk that I'll have to backtrack and try something else. With this in mind, I decide to focus on the logo first.
What's going on with the logo?

Highlighting the <svg> element in devtools, I see a bounding box with a height of 32px as I've set via CSS. However, the rendered image is not filling the height of this box. It almost seems as if the rendered image is being compressed horizontally and this is down-scaling the image beyond what is necessary to fit the height constraint.
After reading some more MDN docs, I'm starting to understand the order of operations when rendering. First, a size is determined for the <svg> element as part of the rendering flow. After the element size is determined, then the SVG content is fit into the element. This order is important because it means the content of the SVG, including its `viewBox`, do not influence the size of the element when rendering.
Setting explicit width and height attributes tells the DOM renderer how to size the <svg> element explicitly. SVGR was doing the hard work of ensuring those attributes matched the original image. Now the DOM renderer is basically guessing how big the image element should be with no information to work from. It knows the height should be 32px because that's specified via CSS. Apparently it concludes the correct corresponding width should be 27.91 pixels (perhaps 28).
With this deeper understanding, I can focus on how to correctly set the aspect ratio when the DOM determines the element size.
Backtracking
I'm beginning to see that I may have wandered down a misleading path after all. SVGR was originally adding width and height attributes which, above all else, communicate the correct aspect ratio of the image to the DOM. Removing these attributes lost that information at a critical step.
Furthermore, I was trying to render the viewBox attribute as it is a prerequisite for the preserveAspectRatio attribute. However, I now understand that preserveAspectRatio is entirely inappropriate for what I'm trying to do. This attribute only factors in when the aspect ratio of the <svg> DOM element does not match the aspect ratio in viewBox (ie, the ratio of the image itself). If I can correctly scale the aspect ratio for the DOM element, then I expect the image to render appropriately within that. One caveat is that the viewBox may be required to correctly render the SVG within a down-scaled DOM element; however, this is secondary to figuring out how to downscale the element itself.
For now, I undo my changes to the SVGR options but keep the CSS reducing the height of the logo. This gets me back to my cropped logo, now armed with a more clear understanding of what's happening.

We're back to a familiar sight, with a correctly-sized menu icon to boot. Now I see what's happening.
The DOM "knows" the correct dimensions of the source image because SVGR has set the height and width to 67x36px. I then set the height to 32px via CSS but do not set a new width. It seems the DOM gives priority to the CSS style over the height attribute which is very helpful indeed. However, it does nothing with the width.
Aside: my current understanding is that the SVG paint algorithm will actually ignore the dimensions of the DOM element entirely when there's no viewBox attribute available. That means we'll initially see clipping on two sides instead of a scaled image when I finally get the DOM element dimensions correct. At that point, I'll come back to the problem of adding a viewBox attribute.
Preserving aspect ratio with CSS
To fix our element dimensions, I return to CSS. My first instinct is that the `aspect-ratio` style would be appropriate for this. However, this adds to my confusion. The only value which would preserve the aspect ratio of the original element is the default value, `auto`. Checking dev tools, I see this default value remains unchanged.

So why isn't the ratio preserved?
And then it all clicks into place. The only reason `aspect-ratio` wouldn't be effective is if both width and height styles are being set. I check the resolved styles to confirm my suspicion.

Indeed, the width attribute on the element is being treated as a CSS style and thus `aspect-ratio: auto` doesn't come into play. To leverage `aspect-ratio: auto`, I'll need to set width to be determined automatically. Unfortunately, I'm not sure what will happen next.
On one hand, the DOM may use the original height and width attributes to determine the correct aspect ratio for the element image. On the other hand, the DOM might be using the final CSS for that purpose and overriding width will simply erase that information once again. There's only one way to find out (well, there's two if we want to read the docs but...). It's time for an experiment!

Bingo! It looks like we get exactly what I was hoping for.

Pulling out my trusty calculator, I confirm the aspect ratio. The original aspect ratio is 67:36 and so, given a height of 32px, we expect the corresponding width to be 32*(67/36) = 59.5556. Ignoring an apparent rounding error, the DOM element is behaving as expected. There's just one little problem left.
We can see the logo is being clipped along two dimensions. I theorize this would happen as the browser can't scale the SVG painting without a viewBox attribute.
Restoring the viewBox attribute
Earlier I learned that the viewBox attribute is present when I set `dimensions: false` in the SVGR options. However, this removes the width and height attributes which I now know to be critical. I suspect there must be an SVGR option to simply not remove the viewBox attribute. Unfortunately, searching the Options page of the SVGR documentation for "viewBox" doesn't turn up anything other than the the single dimensions option.
Eventually, I stumble upon this Github issue for VRGO. There's a lot there but, basically, VRGO removes viewBox by default. The "default" being discussed is actually the `preset-default` plugin. The documentation for SVGR isn't clear about whether this plugin is used or not. Initially I thought not as SVGR docs explicitly mention the `prefixIds` plugin and nothing else; however, the VRGO docs suggest that virtually all SVGO behaviour is dictated by plugins and so it wouldn't do much of anything if only `prefixIds` was enabled. To confirm this, I have to dig into the source of SVGR.
Upon reviewing the SVGR plugin implementation, it seems SVGR does indeed use the `preset-default` plugin for SVGO and, by default, this strips the viewBox. This code also demonstrates that the viewBox is preserved when `dimension: false` by explicitly disabling `removeViewBox` on VRGO for just that case. Now we know where in the process the viewBox attribute is being removed and we should be able to stop that from happening.
I can set the SVGO config manually; however, it seems doing so completely replaces the default config. Luckily, the default SVGO config is very short so I copy the whole thing in.

And with this change, we finally see our correctly-sized and otherwise unadulterated logo.

Phew! That was a lot. Thank you for joining me on this wild journey.
P.S. If you'd like to see the full redesign when it eventually ships, create your Juicy account you'll land in the workspace home screen every time you sign in. If the top corner looks like the screenshot above, then you're looking at the new design (and if not, it'll be out soon)!