I recently wrote a very basic Sass loop that outputs several padding and margin utility classes. Nothing fancy, really, just a Sass map with 11 spacing values, looped over to create classes for both padding and margin on each side. As we’ll see, this works, but it ends up a pretty hefty amount of CSS. We’re going to refactor it to use CSS custom properties and make the system much more trim.
Here’s the original Sass implementation:
$space-stops: (
'0': 0,
'1': 0.25rem,
'2': 0.5rem,
'3': 0.75rem,
'4': 1rem,
'5': 1.25rem,
'6': 1.5rem,
'7': 1.75rem,
'8': 2rem,
'9': 2.25rem,
'10': 2.5rem,
);
@each $key, $val in $space-stops {
.p-#{$key} {
padding: #{$val} !important;
}
.pt-#{$key} {
padding-top: #{$val} !important;
}
.pr-#{$key} {
padding-right: #{$val} !important;
}
.pb-#{$key} {
padding-bottom: #{$val} !important;
}
.pl-#{$key} {
padding-left: #{$val} !important;
}
.px-#{$key} {
padding-right: #{$val} !important;
padding-left: #{$val} !important;
}
.py-#{$key} {
padding-top: #{$val} !important;
padding-bottom: #{$val} !important;
}
.m-#{$key} {
margin: #{$val} !important;
}
.mt-#{$key} {
margin-top: #{$val} !important;
}
.mr-#{$key} {
margin-right: #{$val} !important;
}
.mb-#{$key} {
margin-bottom: #{$val} !important;
}
.ml-#{$key} {
margin-left: #{$val} !important;
}
.mx-#{$key} {
margin-right: #{$val} !important;
margin-left: #{$val} !important;
}
.my-#{$key} {
margin-top: #{$val} !important;
margin-bottom: #{$val} !important;
}
}
This very much works. It outputs all the utility classes we need. But, it can also get bloated quickly. In my case, they were about 8.6kb uncompressed and under 1kb compressed. (Brotli was 542 bytes, and gzip came in at 925 bytes.)
Since they are extremely repetitive, they compress well, but I still couldn’t shake the feeling that all these classes were overkill. Plus, I hadn’t even done any small/medium/large breakpoints which are fairly typical for these kinds of helper classes.
Here’s a contrived example of what the responsive version might look like with small/medium/large classes added. We’ll re-use the $space-stops
map defined previously and throw our repetitious code into a mixin
@mixin finite-spacing-utils($bp: '') {
@each $key, $val in $space-stops {
.p-#{$key}#{$bp} {
padding: #{$val} !important;
}
.pt-#{$key}#{$bp} {
padding-top: #{$val} !important;
}
.pr-#{$key}#{$bp} {
padding-right: #{$val} !important;
}
.pb-#{$key}#{$bp} {
padding-bottom: #{$val} !important;
}
.pl-#{$key}#{$bp} {
padding-left: #{$val} !important;
}
.px-#{$key}#{$bp} {
padding-right: #{$val} !important;
padding-left: #{$val} !important;
}
.py-#{$key}#{$bp} {
padding-top: #{$val} !important;
padding-bottom: #{$val} !important;
}
.m-#{$key}#{$bp} {
margin: #{$val} !important;
}
.mt-#{$key}#{$bp} {
margin-top: #{$val} !important;
}
.mr-#{$key}#{$bp} {
margin-right: #{$val} !important;
}
.mb-#{$key}#{$bp} {
margin-bottom: #{$val} !important;
}
.ml-#{$key}#{$bp} {
margin-left: #{$val} !important;
}
.mx-#{$key}#{$bp} {
margin-right: #{$val} !important;
margin-left: #{$val} !important;
}
.my-#{$key}#{$bp} {
margin-top: #{$val} !important;
margin-bottom: #{$val} !important;
}
}
}
@include finite-spacing-utils;
@media (min-width: 544px) {
@include finite-spacing-utils($bp: '_sm');
}
@media (min-width: 768px) {
@include finite-spacing-utils($bp: '_md');
}
@media (min-width: 1024px) {
@include finite-spacing-utils($bp: '_lg');
}
That clocks in at about 41.7kb uncompressed (and about 1kb with Brotli, and 3kb with gzip). It still compresses well, but it’s a bit ridiculous.
I knew it was possible to reference data-*
attributes from within CSS using the [attr()
function, so I wondered if it was possible to use calc()
and attr()
together to create dynamically-calculated spacing utility helpers via data-*
attributes — like data-m="1"
or data-m="1@md"
— then in the CSS to do something like margin: calc(attr(data-m) * 0.25rem)
(assuming I’m using a spacing scale incrementing at 0.25rem
intervals). That could be very powerful.
But the end of that story is: no, you (currently) can’t use attr()
with any property except the content
property. Bummer. But in searching for attr()
and calc()
information, I found this intriguing Stack Overflow comment by Simon Rigét that suggests setting a CSS variable directly within an inline style attribute. Aha!
So it’s possible to do something like <div style="--p: 4;">
then, in CSS:
:root {
--p: 0;
}
[style*='--p:'] {
padding: calc(0.25rem * var(--p)) !important;
}
In the case of the style="--p: 4;"
example, you’d effectively end up with padding: 1rem !important;
.
… and now you have an infinitely scalable spacing utility class monstrosity helper.
Here’s what that might look like in CSS:
:root {
--p: 0;
--pt: 0;
--pr: 0;
--pb: 0;
--pl: 0;
--px: 0;
--py: 0;
--m: 0;
--mt: 0;
--mr: 0;
--mb: 0;
--ml: 0;
--mx: 0;
--my: 0;
}
[style*='--p:'] {
padding: calc(0.25rem * var(--p)) !important;
}
[style*='--pt:'] {
padding-top: calc(0.25rem * var(--pt)) !important;
}
[style*='--pr:'] {
padding-right: calc(0.25rem * var(--pr)) !important;
}
[style*='--pb:'] {
padding-bottom: calc(0.25rem * var(--pb)) !important;
}
[style*='--pl:'] {
padding-left: calc(0.25rem * var(--pl)) !important;
}
[style*='--px:'] {
padding-right: calc(0.25rem * var(--px)) !important;
padding-left: calc(0.25rem * var(--px)) !important;
}
[style*='--py:'] {
padding-top: calc(0.25rem * var(--py)) !important;
padding-bottom: calc(0.25rem * var(--py)) !important;
}
[style*='--m:'] {
margin: calc(0.25rem * var(--m)) !important;
}
[style*='--mt:'] {
margin-top: calc(0.25rem * var(--mt)) !important;
}
[style*='--mr:'] {
margin-right: calc(0.25rem * var(--mr)) !important;
}
[style*='--mb:'] {
margin-bottom: calc(0.25rem * var(--mb)) !important;
}
[style*='--ml:'] {
margin-left: calc(0.25rem * var(--ml)) !important;
}
[style*='--mx:'] {
margin-right: calc(0.25rem * var(--mx)) !important;
margin-left: calc(0.25rem * var(--mx)) !important;
}
[style*='--my:'] {
margin-top: calc(0.25rem * var(--my)) !important;
margin-bottom: calc(0.25rem * var(--my)) !important;
}
This is a lot like the first Sass loop above, but there’s no loop going 11 times — and yet it’s infinite. It’s about 1.4kb uncompressed, 226 bytes with Brotli, or 284 bytes gzipped.
If you wanted to extend this for breakpoints, the unfortunate news is that you can’t put the “@” character in CSS variable names (although emojis and other UTF-8 characters are strangely permitted). So you could probably set up variable names like p_sm
or sm_p
. You’d have to add some extra CSS variables and some media queries to handle all this, but it won’t blow up exponentially the way traditional CSS classnames created with a Sass for-loop do.
Here’s the equivalent responsive version. We’ll use a Sass mixin again to cut down the repetition:
:root {
--p: 0;
--pt: 0;
--pr: 0;
--pb: 0;
--pl: 0;
--px: 0;
--py: 0;
--m: 0;
--mt: 0;
--mr: 0;
--mb: 0;
--ml: 0;
--mx: 0;
--my: 0;
}
@mixin infinite-spacing-utils($bp: '') {
[style*='--p#{$bp}:'] {
padding: calc(0.25rem * var(--p#{$bp})) !important;
}
[style*='--pt#{$bp}:'] {
padding-top: calc(0.25rem * var(--pt#{$bp})) !important;
}
[style*='--pr#{$bp}:'] {
padding-right: calc(0.25rem * var(--pr#{$bp})) !important;
}
[style*='--pb#{$bp}:'] {
padding-bottom: calc(0.25rem * var(--pb#{$bp})) !important;
}
[style*='--pl#{$bp}:'] {
padding-left: calc(0.25rem * var(--pl#{$bp})) !important;
}
[style*='--px#{$bp}:'] {
padding-right: calc(0.25rem * var(--px#{$bp})) !important;
padding-left: calc(0.25rem * var(--px)#{$bp}) !important;
}
[style*='--py#{$bp}:'] {
padding-top: calc(0.25rem * var(--py#{$bp})) !important;
padding-bottom: calc(0.25rem * var(--py#{$bp})) !important;
}
[style*='--m#{$bp}:'] {
margin: calc(0.25rem * var(--m#{$bp})) !important;
}
[style*='--mt#{$bp}:'] {
margin-top: calc(0.25rem * var(--mt#{$bp})) !important;
}
[style*='--mr#{$bp}:'] {
margin-right: calc(0.25rem * var(--mr#{$bp})) !important;
}
[style*='--mb#{$bp}:'] {
margin-bottom: calc(0.25rem * var(--mb#{$bp})) !important;
}
[style*='--ml#{$bp}:'] {
margin-left: calc(0.25rem * var(--ml#{$bp})) !important;
}
[style*='--mx#{$bp}:'] {
margin-right: calc(0.25rem * var(--mx#{$bp})) !important;
margin-left: calc(0.25rem * var(--mx#{$bp})) !important;
}
[style*='--my#{$bp}:'] {
margin-top: calc(0.25rem * var(--my#{$bp})) !important;
margin-bottom: calc(0.25rem * var(--my#{$bp})) !important;
}
}
@include infinite-spacing-utils;
@media (min-width: 544px) {
@include infinite-spacing-utils($bp: '_sm');
}
@media (min-width: 768px) {
@include infinite-spacing-utils($bp: '_md');
}
@media (min-width: 1024px) {
@include infinite-spacing-utils($bp: '_lg');
}
That’s about 6.1kb uncompressed, 428 bytes with Brotli, and 563 with gzip.
Do I think that writing HTML like <div style="--px:2; --my:4;">
is pleasing to the eye, or good developer ergonomics… no, not particularly. But could this approach be viable in situations where you (for some reason) need extremely minimal CSS, or perhaps no external CSS file at all? Yes, I sure do.
It’s worth pointing out here that CSS variables assigned in inline styles do not leak out. They’re scoped only to the current element and don’t change the value of the variable globally. Thank goodness! The one oddity I have found so far is that DevTools (at least in Chrome, Firefox, and Safari) do not report the styles using this technique in the “Computed” styles tab.
Also worth mentioning is that I’ve used good old padding
and margin
properties with -top
, -right
, -bottom
, and -left
, but you could use the equivalent logical properties like padding-block
and padding-inline
. It’s even possible to shave off just a few more bytes by selectively mixing and matching logical properties with traditional properties. I managed to get it down to 400 bytes with Brotli and 521 with gzip this way.
Other use cases
This seems most appropriate for things that are on a (linear) incremental scale (which is why padding and margin seems like a good use case) but I could see this potentially working for widths and heights in grid systems (column numbers and/or widths). Maybe for typographic scales (but maybe not).
I’ve focused a lot on file size, but there may be some other uses here I’m not thinking of. Perhaps you wouldn’t write your code in this way, but a critical CSS tool could potentially refactor the code to use this approach.
Digging deeper
As I dug deeper, I found that Ahmad Shadeed blogged in 2019 about mixing calc()
with CSS variable assignments within inline styles particularly for avatar sizes. Miriam Suzanne’s article on Smashing Magazine in 2019 didn’t use calc()
but shared some amazing things you can do with variable assignments in inline styles.
This is a really interesting read, thanks! I have recently been using Tailwind in my projects, and while I love the way that it allows me to escape repetitive CSS declarations, I often find myself wishing that it could manipulate my source documents to be more efficient as well! It would be so neat if it had some sort of Babel plugin that converted CSS utility styles into inline variable styles like this, to super-reduce the document size… Oh well, a girl can dream.
Yes! I love this idea. Just the kind of thing I hoped others might think to do with this!
Here’s a CodePen for anyone who wants to play around with the style utilities https://codepen.io/andyford/pen/MWmzxMv
I don’t use inline styles but this technique seems like it was a lot of fun to explore!
As a big utility class fan, I think this is excellent. Very novel approach and a great use of ‘contains’ matching selectors.
I love utility classes! And I absolutely think this is a brilliant discovery! I can see this being used for markup that’s generated.
Writing it as pure vanilla HTML does scare me. The codepen link made me laugh and weep inside. Maybe i’m getting old.
If the issue is just the file size, we can always use PurgeCSS to get rid of the unused CSS right?
I feel like I watered down my “inline css vars are cool” point by getting caught up in sass and file size stuff. Here’s a really basic line-clamp example using only css https://codepen.io/andyford/pen/KKrLEXX