css variables – CSS-Tricks https://css-tricks.com Tips, Tricks, and Techniques on using Cascading Style Sheets. Sat, 12 Feb 2022 17:23:17 +0000 en-US hourly 1 https://wordpress.org/?v=6.1.1 https://i0.wp.com/css-tricks.com/wp-content/uploads/2021/07/star.png?fit=32%2C32&ssl=1 css variables – CSS-Tricks https://css-tricks.com 32 32 45537868 Quick and Dirty Bootstrap Overrides at Runtime https://css-tricks.com/quick-and-dirty-bootstrap-overrides-at-runtime/ https://css-tricks.com/quick-and-dirty-bootstrap-overrides-at-runtime/#comments Wed, 10 Nov 2021 15:00:29 +0000 https://css-tricks.com/?p=355649 Oh, Bootstrap, that old standard web library that either you hate or you spend all your time defending as “it’s fine, it’s not that bad.” Regardless of what side you fall on, it’s a powerful UI framework that’s everywhere, …


Quick and Dirty Bootstrap Overrides at Runtime originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
Oh, Bootstrap, that old standard web library that either you hate or you spend all your time defending as “it’s fine, it’s not that bad.” Regardless of what side you fall on, it’s a powerful UI framework that’s everywhere, most people know the basics of it, and it gives you extremely predictable results.

For better or worse, Bootstrap is opinionated. It wants you to construct your HTML a certain way, it wants you to override styles a certain way, it wants to be built from core files a certain way, and it wants to be included in websites a certain way. Most of the time, unless you have a coworker who writes Bootstrap badly, this is fine, but it doesn’t cover all use cases.

Bootstrap wants to be generated server-side and it does not like having its styles overridden at runtime. If you’re in a situation where you want some sort of visual theme feature in your application, what Bootstrap wants you to do is generate separate stylesheets for each theme and swap out stylesheets as you need. This is a great way to do it if you have pre-defined themes you’re offering to users. But what if you want user-defined themes? You could set up your app to run Sass and compile new stylesheets and save them to the server, but that’s a lot of work—plus you have to go talk to the back-end guys and DevOps which is a bunch of hassle if you only want to, say, swap out primary and secondary colors, for example.

So this is where I was.

I’m building a multi-user SaaS app using Django and Vue with a fixed layout, but also a requirement to be able to change the branding colors for each user account with an automatic default color theme. There is another requirement that we don’t re-deploy the app every time a new user is added. And, finally, every single back-end and DevOps dev is currently swamped with other projects, so I have to solve this problem on my own.

Since I really don’t want to compile Sass at runtime, I could just create stylesheets and inject them into pages, but this is a bad solution since we’re focusing on colors. Compiled Bootstrap stylesheets render out the color values as explicit hex values, and (I just checked) there are 23 different instances of primary blue in my stylesheet. I would need to override every instance of that just for primary colors, then do it again for secondary, warning, danger, and all the other conventions and color standardizations we want to change. It’s complicated and a lot of work. I don’t want to do that.

Luckily, this new app doesn’t have a requirement to support Internet Explorer 11, so that means I have CSS variables at my disposal. They’re great, too, and they can be defined after loading a stylesheet, flowing in every direction and changing all the colors I want, right? And Bootstrap generates that big list of variables in the :root element, so this should be simple.

This is when I learned that Bootstrap only renders some of its values as variables in the stylesheet, and that this list of variables is intended entirely for end-user consumption. Most of the variables in that list ate not referenced in the rest of the stylesheet, so redefining them does nothing. (However, it’s worth a note that better variable support at runtime may be coming in the future.)

So what I want is my Bootstrap stylesheet to render with CSS variables that I can manipulate on the server side instead of static color values, and strictly speaking, that’s not possible. Sass won’t compile if you set color variables as CSS variables. There are a couple of clever tricks available to make Sass do this (here’s one, and another), but they require branching Bootstrap, and branching away from the upgrade path introduces a bit of brittleness to my app that I’m unwilling to add. And if I’m perfectly honest, the real reason I didn’t implement those solutions was that I couldn’t figure out how to make any of them work with my Sass compiler. But you might have better luck.

This is where I think it’s worth explaining my preferred workflow. I prefer to run Sass locally on my dev machine to build stylesheets and commit the compiled stylesheets to the repo. Best practices would suggest the stylesheets should be compiled during deployment, and that’s correct, but I work for a growing, perpetually understaffed startup. I work with Sass because I like it, but in what is clearly a theme for my job, I don’t have the time, power or spiritual fortitude to integrate my Sass build with our various deployment pipelines.

It’s also a bit of lawful evil self-defense: I don’t want our full-stack developers to get their mitts on my finely-crafted styles and start writing whatever they want; and I’ve discovered that for some reason they have a terrible time getting Node installed on their laptops. Alas! They just are stuck asking me to do it, and that’s exactly how I want things.

All of which is to say: if I can’t get the stylesheets to render with the variables in it, there’s nothing stopping me from injecting the variables into the stylesheet after it’s been compiled.

Behold the power of find and replace!

What we do is go into Bootstrap and find the colors we want to replace, conveniently found at the top of your compiled stylesheet in the :root style:

:root {
  --bs-blue: #002E6D;
  --bs-indigo: #6610F2;
  --bs-purple: #6F42C1;
  --bs-pink: #E83E8C;
  --bs-red: #DC3545;
  --bs-orange: #F2581C;
  --bs-yellow: #FFC107;
  --bs-green: #28A745;
  --bs-teal: #0C717A;
  --bs-cyan: #007DBC;
  --bs-white: #fff;
  --bs-gray: #6c757d;
  --bs-gray-dark: #343a40;
  --bs-gray-100: #f8f9fa;
  --bs-gray-200: #e9ecef;
  --bs-gray-300: #dee2e6;
  --bs-gray-400: #ced4da;
  --bs-gray-500: #adb5bd;
  --bs-gray-600: #6c757d;
  --bs-gray-700: #495057;
  --bs-gray-800: #343a40;
  --bs-gray-900: #212529;
  --bs-primary: #002E6D;
  --bs-brand: #DC3545;
  --bs-secondary: #495057;
  --bs-success: #28A745;
  --bs-danger: #DC3545;
  --bs-warning: #FFC107;
  --bs-info: #007DBC;
  --bs-light: #fff;
  --bs-dark: #212529;
  --bs-background-color: #e9ecef;
  --bs-bg-light: #f8f9fa;
  --bs-primary-rgb: 13, 110, 253;
  --bs-secondary-rgb: 108, 117, 125;
  --bs-success-rgb: 25, 135, 84;
  --bs-info-rgb: 13, 202, 240;
  --bs-warning-rgb: 255, 193, 7;
  --bs-danger-rgb: 220, 53, 69;
  --bs-light-rgb: 248, 249, 250;
  --bs-dark-rgb: 33, 37, 41;
  --bs-white-rgb: 255, 255, 255;
  --bs-black-rgb: 0, 0, 0;
  --bs-body-rgb: 33, 37, 41;
  --bs-font-sans-serif: system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, Liberation Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
  --bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace;
  --bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
  --bs-body-font-family: Source Sans Pro;
  --bs-body-font-size: 1rem;
  --bs-body-font-weight: 400;
  --bs-body-line-height: 1.5;
  --bs-body-color: #212529;
  --bs-body-bg: #e9ecef;
}

Grab the value for, say, --bs-primary, the good ol’ Bootstrap blue. I use Gulp to compile my stylesheets, so let’s take a look at the Sass task function for that in the gulpfile.js:

var gulp = require('gulp');
var sass = require('gulp-sass')(require('sass'));
var sourcemaps = require('gulp-sourcemaps');

function sassCompile() {
  return gulp.src('static/sass/project.scss')
  .pipe(sourcemaps.init())
  .pipe(sass({outputStyle: 'expanded'}))
  .pipe(sourcemaps.write('.'))
  .pipe(gulp.dest('/static/css/'));
}
exports.sass = sassCompile;

I want to copy and replace this color throughout my entire stylesheet with a CSS variable, so I installed gulp-replace to do that. We want our find-and-replace to happen at the very end of the process, after the stylesheet is compiled but before it’s saved. That means we ought to put the pipe at the end of the sequence, like so:

var gulp = require('gulp');
var sass = require('gulp-sass')(require('sass'));
var sourcemaps = require('gulp-sourcemaps');
var gulpreplace = require('gulp-replace');
function sassCompile() {
  return gulp.src('static/sass/project.scss')
    .pipe(sourcemaps.init())
    .pipe(sass({outputStyle: 'expanded'}))
    .pipe(sourcemaps.write('.'))
    .pipe(gulpreplace(/#002E6D/ig, 'var(--ct-primary)'))
    .pipe(gulp.dest('static/css/'));
}
exports.sass = sassCompile; 

Compile the stylesheet, and check it out.

:root {
  --bs-blue: var(--ct-primary);
  --bs-indigo: #6610F2;
  --bs-purple: #6F42C1;
  --bs-pink: #E83E8C;
  --bs-red: #DC3545;
  --bs-orange: #F2581C;
  --bs-yellow: #FFC107;
  --bs-green: #28A745;
  --bs-teal: #0C717A;
  --bs-cyan: #007DBC;
  --bs-white: #fff;
  --bs-gray: #6c757d;
  --bs-gray-dark: #343a40;
  --bs-gray-100: #f8f9fa;
  --bs-gray-200: #e9ecef;
  --bs-gray-300: #dee2e6;
  --bs-gray-400: #ced4da;
  --bs-gray-500: #adb5bd;
  --bs-gray-600: #6c757d;
  --bs-gray-700: #495057;
  --bs-gray-800: #343a40;
  --bs-gray-900: #212529;
  --bs-primary: var(--ct-primary);
  --bs-brand: #DC3545;
  --bs-secondary: #495057;
  --bs-success: #28A745;
  --bs-danger: #DC3545;
  --bs-warning: #FFC107;
  --bs-info: #007DBC;
  --bs-light: #fff;
  --bs-dark: #212529;
  --bs-background-color: #e9ecef;
  --bs-bg-light: #f8f9fa;
  --bs-primary-rgb: 13, 110, 253;
  --bs-secondary-rgb: 108, 117, 125;
  --bs-success-rgb: 25, 135, 84;
  --bs-info-rgb: 13, 202, 240;
  --bs-warning-rgb: 255, 193, 7;
  --bs-danger-rgb: 220, 53, 69;
  --bs-light-rgb: 248, 249, 250;
  --bs-dark-rgb: 33, 37, 41;
  --bs-white-rgb: 255, 255, 255;
  --bs-black-rgb: 0, 0, 0;
  --bs-body-rgb: 33, 37, 41;
  --bs-font-sans-serif: system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, Liberation Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
  --bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace;
  --bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
  --bs-body-font-family: Source Sans Pro;
  --bs-body-font-size: 1rem;
  --bs-body-font-weight: 400;
  --bs-body-line-height: 1.5;
  --bs-body-color: #212529;
  --bs-body-bg: #e9ecef;
}

Cool, OK, we now have an entire stylesheet that wants a variable value for blue. Notice it changed both the primary color and the “blue” color. This isn’t a subtle technique. I call it quick-and-dirty for a reason, but it’s fairly easy to get more fine-grained control of your color replacements if you need them. For instance, if you want to keep “blue” and “primary” as separate values, go into your Sass and redefine the $blue and $primary Sass variables into different values, and then you can separately find-and-replace them as needed.

Next, we need to define our new default variable value in the app. It’s as simple as doing this in the HTML head:

<link href="/static/css/project.css" rel="stylesheet">
<style>
  :root {
    --ct-primary: #002E6D;
  }
</style>

Run that and everything shows up. Everything that needs to be blue is blue. Repeat this process a few times, and you suddenly have lots of control over the colors in your Bootstrap stylesheet. These are the variables I’ve chosen to make available to users, along with their default color values:

--ct-primary: #002E6D;
--ct-primary-hover: #00275d;
--ct-secondary: #495057;
--ct-secondary-hover: #3e444a;
--ct-success: #28A745;
--ct-success-hover: #48b461;
--ct-danger: #DC3545;
--ct-danger-hover: #bb2d3b;
--ct-warning: #FFC107;
--ct-warning-hover: #ffca2c;
--ct-info: #007DBC;
--ct-info-hover: #006aa0;
--ct-dark: #212529;
--ct-background-color: #e9ecef;
--ct-bg-light: #f8f9fa;
--bs-primary-rgb: 0, 46, 109;
--bs-secondary-rgb: 73, 80, 87;
--bs-success-rgb: 40, 167, 69;
--bs-info-rgb: 0, 125, 188;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-body-rgb: 33, 37, 41;

Now the fun begins! From here, you can directly manipulate these defaults if you like, or add a second :root style below the defaults to override only the colors you want. Or do what I do, and put a text field in the user profile that outputs a :root style into your header overriding whatever you need. Voilà, you can now override Bootstrap at runtime without recompiling the stylesheet or losing your mind.

This isn’t an elegant solution, certainly, but it solves a very specific use case that developers have been trying to solve for years now. And until Bootstrap decides it wants to let us easily override variables at runtime, this has proven to be a very effective solution for me.


Quick and Dirty Bootstrap Overrides at Runtime originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
https://css-tricks.com/quick-and-dirty-bootstrap-overrides-at-runtime/feed/ 2 355649
Using Absolute Value, Sign, Rounding and Modulo in CSS Today https://css-tricks.com/using-absolute-value-sign-rounding-and-modulo-in-css-today/ https://css-tricks.com/using-absolute-value-sign-rounding-and-modulo-in-css-today/#comments Wed, 28 Jul 2021 14:37:34 +0000 https://css-tricks.com/?p=320544 For quite a while now, the CSS spec has included a lot of really useful mathematical functions, such as trigonometric functions (sin(), cos(), tan(), asin(), acos(), atan(), atan2()), exponential functions (…


Using Absolute Value, Sign, Rounding and Modulo in CSS Today originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
For quite a while now, the CSS spec has included a lot of really useful mathematical functions, such as trigonometric functions (sin(), cos(), tan(), asin(), acos(), atan(), atan2()), exponential functions (pow(), exp(), sqrt(), log(), hypot()), sign-related functions (abs(), sign()) and stepped value functions (round(), mod(), rem()).

However, these are not yet implemented in any browser, so this article is going to show how, using CSS features we already have, we can compute the values that abs(), sign(), round() and mod() should return. And then we’ll see what cool things this allows us to build today.

Screenshot collage - a 2x2 grid. The first one shows the items of a full-screen navigation sliding down with a delay that's proportional to the distance to the selected one. The second one shows a cube with each face made of neon tiles; these tiles shrink and go inwards, into the cube, with a delay that depends on the distance from the midlines of the top face. The third one is a time progress with a tooltip showing the elapsed time in a mm::ss format. The fourth one is a 3D rotating musical toy with wooden and metallic stars and a wooden crescent moon hanging from the top.
A few of the things these functions allow us to make.

Note that none of these techniques were ever meant to work in browsers from back in the days when dinosaurs roamed the internet. Some of them even depend on the browser supporting the ability to register custom properties (using @property), which means they’re limited to Chromium for now.

The computed equivalents

--abs

We can get this by using the new CSS max() function, which is already implemented in the current versions of all major browsers.

Let’s say we have a custom property, --a. We don’t know whether this is positive or negative and we want to get its absolute value. We do this by picking the maximum between this value and its additive inverse:

--abs: max(var(--a), -1*var(--a));

If --a is positive, this means it’s greater than zero, and multiplying it with -1 gives us a negative number, which is always smaller than zero. That, in turn, is always smaller than the positive --a, so the result returned by max() is equal to var(--a).

If --a is negative, this means it’s smaller than zero, and that multiplying it by -1 gives us a positive number, which is always bigger than zero, which, in turn, is always bigger than the negative --a. So, the result returned by max() is equal to -1*var(--a).

--sign

This is something we can get using the previous section as the sign of a number is that number divided by its absolute value:

--abs: max(var(--a), -1*var(--a));
--sign: calc(var(--a)/var(--abs));

A very important thing to note here is that this only works if --a is unitless, as we cannot divide by a number with a unit inside calc().

Also, if --a is 0, this solution works only if we register --sign (this is only supported in Chromium browsers at this point) with an initial-value of 0:

@property --sign {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false /* or true depending on context */
}

This is because --a, being 0, also makes --abs compute to 0 — and dividing by 0 is invalid in CSS calc() — so we need to make sure --sign gets reset to 0 in this situation. Keep in mind that this does not happen if we simply set it to 0 in the CSS prior to setting it to the calc() value and we don’t register it:

--abs: max(var(--a), -1*var(--a));
--sign: 0; /* doesn't help */
--sign: calc(var(--a)/var(--abs));

In practice, I’ve also often used the following version for integers:

--sign: clamp(-1, var(--a), 1);

Here, we’re using a clamp() function. This takes three arguments: a minimum allowed value -1, a preferred value var(--a) and a maximum allowed value, 1. The value returned is the preferred value as long as it’s between the lower and upper bounds and the limit that gets exceeded otherwise.

If --a is a negative integer, this means it’s smaller or equal to -1, the lower bound (or the minimum allowed value) of our clamp() function, so the value returned is -1. If it’s a positive integer, this means it’s greater or equal to 1, the upper bound (or the maximum allowed value) of the clamp() function, so the value returned is 1. And finally, if --a is 0, it’s between the lower and upper limits, so the function returns its value (0 in this case).

This method has the advantage of being simpler without requiring Houdini support. That said, note that it only works for unitless values (comparing a length or an angle value with integers like ±1 is like comparing apples and oranges — it doesn’t work!) that are either exactly 0 or at least as big as 1 in absolute value. For a subunitary value, like -.05, our method above fails, as the value returned is -.05, not -1!

My first thought was that we can extend this technique to subunitary values by introducing a limit value that’s smaller than the smallest non-zero value we know --a can possibly take. For example, let’s say our limit is .000001 — this would allow us to correctly get -1 as the sign for -.05, and 1 as the sign for .0001!

--lim: .000001;
--sign: clamp(-1*var(--lim), var(--a), var(--lim));

Temani Afif suggested a simpler version that would multiply --a by a very large number in order to produce a superunitary value.

--sign: clamp(-1, var(--a)*10000, 1);

I eventually settled on dividing --a by the limit value because it just feels a bit more intuitive to see what minimum non-zero value it won’t go below.

--lim: .000001;
--sign: clamp(-1, var(--a)/var(--lim), 1);

--round (as well as --ceil and --floor)

This is one I was stuck on for a while until I got a clever suggestion for a similar problem from Christian Schaefer. Just like the case of the sign, this only works on unitless values and requires registering the --round variable as an <integer> so that we force rounding on whatever value we set it to:

@property --round {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false /* or true depending on context */
}

.my-elem { --round: var(--a); }

By extension, we can get --floor and --ceil if we subtract or add .5:

@property --floor {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false /* or true depending on context */
}

@property --ceil {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false /* or true depending on context */
}

.my-elem {
  --floor: calc(var(--a) - .5);
  --ceil: calc(var(--a) + .5)
}

--mod

This builds on the --floor technique in order to get an integer quotient, which then allows us to get the modulo value. This means that both our values must be unitless.

@property --floor {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false /* or true depending on context */
}

.my-elem {
  --floor: calc(var(--a)/var(--b) - .5);
  --mod: calc(var(--a) - var(--b)*var(--floor))
}

Use cases

What sort of things can we do with the technique? Let’s take a good look at three use cases.

Effortless symmetry in staggered animations (and not only!)

While the absolute value can help us get symmetrical results for a lot of properties, animation-delay and transition-delay are the ones where I’ve been using it the most, so let’s see some examples of that!

We put --n items within a container, each of these items having an index --i. Both --n and --i are variables we pass to the CSS via style attributes.

- let n = 16;

.wrap(style=`--n: ${n}`)
  - for(let i = 0; i < n; i++)
    .item(style=`--i: ${i}`)

This gives us the following compiled HTML:

<div class='wrap' style='--n: 16'>
  <div class='item' style='--i: 0'></div>
  <div class='item' style='--i: 1'></div>
  <!-- more such items -->
</div>

We set a few styles such that the items are laid out in a row and are square with a non-zero edge length:

$r: 2.5vw;

.wrap {
  display: flex;
  justify-content: space-evenly;
}

.item { padding: $r; }
Screenshot showing the items lined in a row and DevTools with the HTML structure and the styles applied.
The result so far.

Now we add two sets of keyframes to animate a scaling transform and a box-shadow. The first set of keyframes, grow, makes our items scale up from nothing at 0% to full size at 50%, after which they stay at their full size until the end. The second set of keyframes, melt, shows us the items having inset box shadows that cover them fully up to the midway point in the animation (at 50%). That’s also when the items reach full size after growing from nothing. Then the spread radius of these inset shadows shrinks until it gets down to nothing at 100%.

$r: 2.5vw;

.item {
  padding: $r;
  animation: a $t infinite;
  animation-name: grow, melt;
}

@keyframes grow {
  0% { transform: scale(0); }
  50%, 100% { transform: none; }
}

@keyframes melt {
  0%, 50% { box-shadow: inset 0 0 0 $r; }
  100% { box-shadow: inset 0 0; }
}
Animated gif. Shows 16 black square tiles in a row growing from nothing to full size, then melting from the inside until they disappear. The cycle then repeats. In this case, all tiles animate at the same time.
The base animation (live demo).

Now comes the interesting part! We compute the middle between the index of the first item and that of the last one. This is the arithmetic mean of the two (since our indices are zero-based, the first and last are 0 and n - 1 respectively):

--m: calc(.5*(var(--n) - 1));

We get the absolute value, --abs, of the difference between this middle, --m, and the item index, --i, then use it to compute the animation-delay:

--abs: max(var(--m) - var(--i), var(--i) - var(--m));
animation: a $t calc(var(--abs)/var(--m)*#{$t}) infinite backwards;
animation-name: grow, melt;

The absolute value ,--abs, of the difference between the middle, --m, and the item index, --i, can be as small as 0 (for the middle item, if --n is odd) and as big as --m (for the end items). This means dividing it by --m always gives us a value in the [0, 1] interval, which we then multiply with the animation duration $t to ensure every item has a delay between 0s and the animation-duration.

Note that we’ve also set animation-fill-mode to backwards. Since most items will start the animations later, this tells the browser to keep them with the styles in the 0% keyframes until then.

In this particular case, we wouldn’t see any difference without it either because, while the items would be at full size (not scaled to nothing like in the 0% keyframe of the grow animation), they would also have no box-shadow until they start animating. However, in a lot of other cases, it does make a difference and we shouldn’t forget about it.

Another possibility (one that doesn’t involve setting the animation-fill-mode) would be to ensure the animation-delay is always smaller or at most equal to 0 by subtracting a full animation-duration out of it.

--abs: max(var(--m) - var(--i), var(--i) - var(--m));
animation: a $t calc((var(--abs)/var(--m) - 1)*#{$t}) infinite;
animation-name: grow, melt;

Both options are valid, and which one you use depends on what you prefer to happen at the very beginning. I generally tend to go for negative delays because they make more sense when recording the looping animation to make a gif like the one below, which illustrates how the animation-delay values are symmetrical with respect to the middle.

Animated gif. Shows 16 black square tiles in a row, each of them growing from nothing to full size, then melting from the inside until they disappear, with the cycle then repeating. Only now, they don't all animate at the same time. The closer they are to the middle, the sooner they start their animation, those at the very ends of the row being one full cycle behind those in the very middle.
The staggered looping animation.

For a visual comparison between the two options, you can rerun the following demo to see what happens at the very beginning.

A fancier example would be the following:

Navigation links sliding up and then back down with a delay proportional to how far they are from the selected one.

Here, each and every one of the --n navigation links and corresponding recipe articles have an index --idx. Whenever a navigation link is hovered or focused, its --idx value is read and set to the current index, --k, on the body. If none of these items is hovered or focused, --k gets set to a value outside the [0, n) interval (e.g. -1).

The absolute value, --abs, of the difference between --k and a link’s index, --idx, can tell us whether that’s the currently selected (hovered or focused) item. If this absolute value is 0, then our item is the currently selected one (i.e. --not-sel is 0 and --sel is 1). If this absolute value is bigger than 0, then our item is not the currently selected one (i.e. --not-sel is 1 and --sel is 0).

Given both --idx and --k are integers, it results that their difference is also an integer. This means the absolute value, --abs, of this difference is either 0 (when the item is selected), or bigger or equal to 1 (when the item is not selected).

When we put all of this into code, this is what we get:

--abs: Max(var(--k) - var(--idx), var(--idx) - var(--k));
--not-sel: Min(1, var(--abs));
--sel: calc(1 - var(--not-sel));

The --sel and --not-sel properties (which are always integers that always add up to 1) determine the size of the navigation links (the width in the wide screen scenario and the height in the narrow screen scenario), whether they’re greyscaled or not and whether or not their text content is hidden. This is something we won’t get into here, as it is outside the scope of this article and I’ve already explained in a lot of detail in a previous one.

What is relevant here is that, when a navigation link is clicked, it slides out of sight (up in the wide screen case, and left in the narrow screen case), followed by all the others around it, each with a transition-delay that depends on how far they are from the one that was clicked (that is, on the absolute value, --abs, of the difference between their index, --idx, and the index of the currently selected item, --k), revealing the corresponding recipe article. These transition-delay values are symmetrical with respect to the currently selected item.

transition: transform 1s calc(var(--abs)*.05s);

The actual transition and delay are actually a bit more complex because more properties than just the transform get animated and, for transform in particular, there’s an additional delay when going back from the recipe article to the navigation links because we wait for the <article> element to disappear before we let the links slide down. But what were’re interested in is that component of the delay that makes the links is closer to the selected one start sliding out of sight before those further away. And that’s computed as above, using the --abs variable.

You can play with the interactive demo below.

Things get even more interesting in 2D, so let’s now make our row a grid!

We start by changing the structure a bit so that we have 8 columns and 8 rows (which means we have 8·8 = 64 items in total on the grid).

- let n = 8;
- let m = n*n;

style
  - for(let i = 0; i < n; i++)
    | .item:nth-child(#{n}n + #{i + 1}) { --i: #{i} }
    | .item:nth-child(n + #{n*i + 1}) { --j: #{i} }
.wrap(style=`--n: ${n}`)
  - for(let i = 0; i < m; i++)
    .item

The above Pug code compiles to the following HTML:

<style>
  .item:nth-child(8n + 1) { --i: 0 } /* items on 1st column */
  .item:nth-child(n + 1) { --j: 0 } /* items starting from 1st row */
  .item:nth-child(8n + 2) { --i: 1 } /* items on 2nd column */
  .item:nth-child(n + 9) { --j: 1 } /* items starting from 2nd row */
  /* 6 more such pairs */
</style>
<div class='wrap' style='--n: 8'>
  <div class='item'></div>
  <div class='item'></div>
  <!-- 62 more such items -->
</div>

Just like the previous case, we compute a middle index, --m, but since we’ve moved from 1D to 2D, we now have two differences in absolute value to compute, one for each of the two dimensions (one for the columns, --abs-i, and one for the rows, --abs-j).

--m: calc(.5*(var(--n) - 1));
--abs-i: max(var(--m) - var(--i), var(--i) - var(--m));
--abs-j: max(var(--m) - var(--j), var(--j) - var(--m));

We use the exact same two sets of @keyframes, but the animation-delay changes a bit, so it depends on both --abs-i and --abs-j. These absolute values can be as small as 0 (for tiles in the dead middle of the columns and rows) and as big as --m (for tiles at the ends of the columns and rows), meaning that the ratio between either of them and --m is always in the [0, 1] interval. This means the sum of these two ratios is always in the [0, 2] interval. If we want to reduce it to the [0, 1] interval, we need to divide it by 2 (or multiply by .5, same thing).

animation-delay: calc(.5*(var(--abs-i)/var(--m) + var(--abs-j)/var(--m))*#{$t});

This gives us delays that are in the [0s, $t] interval. We can take the denominator, var(--m), out of the parenthesis to simplify the above formula a bit:

animation-delay: calc(.5*(var(--abs-i) + var(--abs-j))/var(--m)*#{$t});

Just like the previous case, this makes grid items start animating later the further they are from the middle of the grid. We should use animation-fill-mode: backwards to ensure they stay in the state specified by the 0% keyframes until the delay time has elapsed and they start animating.

Alternatively, we can subtract one animation duration $t from all delays to make sure all grid items have already started their animation when the page loads.

animation-delay: calc((.5*(var(--abs-i) + var(--abs-j))/var(--m) - 1)*#{$t});

This gives us the following result:

Animated gif. Shows an 8x8 grid of tiles, each of them growing from nothing to full size, then melting from the inside until they disappear, with the cycle then repeating. The smaller the sum of their distances to the middle is, the sooner they start their animation, those at the very corners of the grid being one full cycle behind those in the very middle.
The staggered 2D animation (live demo).

Let’s now see a few more interesting examples. We won’t be going into details about the “how” behind them as the symmetrical value technique works exactly the same as for the previous ones and the rest is outside the scope of this article. However, there is a link to a CodePen demo in the caption for each of the examples below, and most of these Pens also come with a recording that shows me coding them from scratch.

In the first example, each grid item is made up of two triangles that shrink down to nothing at opposite ends of the diagonal they meet along and then grow back to full size. Since this is an alternating animation, we let the delays to stretch across two iterations (a normal one and a reversed one), which means we don’t divide the sum of ratios in half anymore and we subtract 2 to ensure every item has a negative delay.

animation: s $t ease-in-out infinite alternate;
animation-delay: calc(((var(--abs-i) + var(--abs-j))/var(--m) - 2)*#{$t});
Grid wave: pulsing triangles (live demo)

In the second example, each grid item has a gradient at an angle that animates from 0deg to 1turn. This is possible via Houdini as explained in this article about the state of animating gradients with CSS.

Field wave: cell gradient rotation (live demo)

The third example is very similar, except the animated angle is used by a conic-gradient instead of a linear one and also by the hue of the first stop.

Rainbow hour wave (live demo)

In the fourth example, each grid cell contains seven rainbow dots that oscillate up and down. The oscillation delay has a component that depends on the cell indices in the exact same manner as the previous grids (the only thing that’s different here is the number of columns differs from the number of rows, so we need to compute two middle indices, one along each of the two dimensions) and a component that depends on the dot index, --idx, relative to the number of dots per cell, --n-dots.

--k: calc(var(--idx)/var(--n-dots));
--mi: calc(.5*(var(--n-cols) - 1));
--abs-i: max(var(--mi) - var(--i), var(--i) - var(--mi));
--mj: calc(.5*(var(--n-rows) - 1));
--abs-j: max(var(--mj) - var(--j), var(--j) - var(--mj));
animation-delay: 
  calc((var(--abs-i)/var(--mi) + var(--abs-j)/var(--mj) + var(--k) - 3)*#{$t});
Rainbow dot wave: dot oscillation (live demo)

In the fifth example, the tiles making up the cube faces shrink and move inwards. The animation-delay for the top face is computed exactly as in our first 2D demo.

Breathe into me: neon waterfall (live demo and a previous iteration)

In the sixth example, we have a grid of columns oscillating up and down.

Column wave (live demo)

The animation-delay isn’t the only property we can set to have symmetrical values. We can also do this with the items’ dimensions. In the seventh example below, the tiles are distributed around half a dozen rings starting from the vertical (y) axis and are scaled using a factor that depends on how far they are from the top point of the rings. This is basically the 1D case with the axis curved on a circle.

Circular grid melt (live demo)

The eighth example shows ten arms of baubles that wrap around a big sphere. The size of these baubles depends on how far they are from the poles, the closest ones being the smallest. This is done by computing the middle index, --m, for the dots on an arm and the absolute value, --abs, of the difference between it and the current bauble index, --j, then using the ratio between this absolute value and the middle index to get the sizing factor, --f, which we then use when setting the padding.

--m: calc(.5*(var(--n-dots) - 1));
--abs: max(var(--m) - var(--j), var(--j) - var(--m));
--f: calc(1.05 - var(--abs)/var(--m));
padding: calc(var(--f)*#{$r});
Travel inside the sphere (live demo)

Different styles for items before and after a certain (selected or middle) one

Let’s say we have a bunch of radio buttons and labels, with the labels having an index set as a custom property, --i. We want the labels before the selected item to have a green background, the label of the selected item to have a blue background and the rest of the labels to be grey. On the body, we set the index of the currently selected option as another custom property, --k.

- let n = 8;
- let k = Math.round((n - 1)*Math.random());

body(style=`--k: ${k}`)
  - for(let i = 0; i < n; i++)
    - let id = `r${i}`;
    input(type='radio' name='r' id=id checked=i===k)
    label(for=id style=`--i: ${i}`) Option ##{i}

This compiles to the following HTML:

<body style='--k: 1'>
  <input type='radio' name='r' id='r0'/>
  <label for='r0' style='--i: 0'>Option #0</label>
  <input type='radio' name='r' id='r1' checked='checked'/>
  <label for='r1' style='--i: 1'>Option #1</label>
  <input type='radio' name='r' id='r2'/>
  <label for='r2' style='--i: 2'>Option #2</label>
  <!-- more options -->
</body>

We set a few layout and prettifying styles, including a gradient background on the labels that creates three vertical stripes, each occupying a third of the background-size (which, for now, is just the default 100%, the full element width):

$c: #6daa7e, #335f7c, #6a6d6b;

body {
  display: grid;
  grid-gap: .25em 0;
  grid-template-columns: repeat(2, max-content);
  align-items: center;
  font: 1.25em/ 1.5 ubuntu, trebuchet ms, sans-serif;
}

label {
  padding: 0 .25em;
  background: 
    linear-gradient(90deg, 
      nth($c, 1) 33.333%, 
      nth($c, 2) 0 66.667%, 
      nth($c, 3) 0);
  color: #fff;
  cursor: pointer;
}
Screenshot showing radio inputs and their labels on two grid columns. The labels have a vertical three stripe background with the first stripe being green, the second one blue and the last one grey.
The result so far.

From the JavaScript, we update the value of --k whenever we select a different option:

addEventListener('change', e => {
  let _t = e.target;
	
  document.body.style.setProperty('--k', +_t.id.replace('r', ''))
})

Now comes the interesting part! For our label elements, we compute the sign, --sgn, of the difference between the label index, --i, and the index of the currently selected option, --k. We then use this --sgn value to compute the background-position when the background-size is set to 300% — that is, three times the label’s width because we may have of three possible backgrounds: one for the case when the label is for an option before the selected one, a second for the case when the label is for the selected option, and a third for the case when the label is for an option after the selected one.

--sgn: clamp(-1, var(--i) - var(--k), 1);
background: 
  linear-gradient(90deg, 
      nth($c, 1) 33.333%, 
      nth($c, 2) 0 66.667%, 
      nth($c, 3) 0) 
    calc(50%*(1 + var(--sgn)))/ 300%

If --i is smaller than --k (the case of a label for an option before the selected one), then --sgn is -1 and the background-position computes to 50%*(1 + -1) = 50%*0 = 0%, meaning we only see the first vertical stripe (the green one).

If --i is equal --k (the case of the label for the selected option), then --sgn is 0 and the background-position computes to 50%*(1 + 0) = 50%*1 = 50%, so we only see the vertical stripe in the middle (the blue one).

If --i is greater than --k (the case of a label for an option after the selected one), then --sgn is 1 and the background-position computes to 50%*(1 + 1) = 50%*2 = 100%, meaning we only see the last vertical stripe (the grey one).

A more aesthetically appealing example would be the following navigation where the vertical bar is on the side closest to the selected option and, for the selected one, it spreads across the entire element.

This uses a structure that’s similar to that of the previous demo, with radio inputs and labels for the navigation items. The moving “background” is actually an ::after pseudo-element whose translation value depends on the sign, --sgn. The text is a ::before pseudo-element whose position is supposed to be in the middle of the white area, so its translation value also depends on --sgn.

/* relevant styles */
label {
  --sgn: clamp(-1, var(--k) - var(--i), 1);
  
  &::before {
    transform: translate(calc(var(--sgn)*-.5*#{$pad}))
  }
  &::after {
    transform: translate(calc(var(--sgn)*(100% - #{$pad})))
  }
}

Let’s now quickly look at a few more demos where computing the sign (and maybe the absolute value as well) comes in handy.

First up, we have a square grid of cells with a radial-gradient whose radius shrinks from covering the entire cell to nothing. This animation has a delay computed as explained in the previous section. What’s new here is that the coordinates of the radial-gradient circle depend on where the cell is positioned with respect to the middle of the grid — that is, on the signs of the differences between the column --i and row --j indices and the middle index, --m.

/* relevant CSS */
$t: 2s;

@property --p {
  syntax: '<length-percentage>';
  initial-value: -1px;
  inherits: false;
}

.cell {
  --m: calc(.5*(var(--n) - 1));
  --dif-i: calc(var(--m) - var(--i));
  --abs-i: max(var(--dif-i), -1*var(--dif-i));
  --sgn-i: clamp(-1, var(--dif-i)/.5, 1);
  --dif-j: calc(var(--m) - var(--j));
  --abs-j: max(var(--dif-j), -1*var(--dif-j));
  --sgn-j: clamp(-1, var(--dif-j)/.5, 1);
  background: 
    radial-gradient(circle
      at calc(50% + 50%*var(--sgn-i)) calc(50% + 50%*var(--sgn-j)), 
      currentcolor var(--p), transparent calc(var(--p) + 1px))
      nth($c, 2);
  animation-delay: 
    calc((.5*(var(--abs-i) + var(--abs-j))/var(--m) - 1)*#{$t});
}

@keyframes p { 0% { --p: 100%; } }
Sinking feeling (live demo)

Then we have a double spiral of tiny spheres where both the sphere diameter --d and the radial distance --x that contributes to determining the sphere position depend on the absolute value --abs of the difference between each one’s index, --i, and the middle index, --m. The sign, --sgn, of this difference is used to determine the spiral rotation direction. This depends on where each sphere is with respect to the middle – that is, whether its index ,--i, is smaller or bigger than the middle index, --m.

/* relevant styles */
--m: calc(.5*(var(--p) - 1));
--abs: max(calc(var(--m) - var(--i)), calc(var(--i) - var(--m)));
--sgn: clamp(-1, var(--i) - var(--m), 1);
--d: calc(3px + var(--abs)/var(--p)*#{$d}); /* sphere diameter */
--a: calc(var(--k)*1turn/var(--n-dot)); /* angle used to determine sphere position */
--x: calc(var(--abs)*2*#{$d}/var(--n-dot)); /* how far from spiral axis */
--z: calc((var(--i) - var(--m))*2*#{$d}/var(--n-dot)); /* position with respect to screen plane */
width: var(--d); height: var(--d);
transform: 
  /* change rotation direction by changing x axis direction */
  scalex(var(--sgn)) 
  rotate(var(--a)) 
  translate3d(var(--x), 0, var(--z)) 
  /* reverse rotation so the sphere is always seen from the front */
  rotate(calc(-1*var(--a))); 
  /* reverse scaling so lighting on sphere looks consistent */
  scalex(var(--sgn))
No perspective (live demo)

Finally, we have a grid of non-square boxes with a border. These boxes have a mask created using a conic-gradient with an animated start angle, --ang. Whether these boxes are flipped horizontally or vertically depends on where they are with respect to the middle – that is, on the signs of the differences between the column --i and row --j indices and the middle index, --m. The animation-delay depends on the absolute values of these differences and is computed as explained in the previous section. We also have a gooey filter for a nicer “wormy” look, but we won’t be going into that here.

/* relevant CSS */
$t: 1s;

@property --ang {
  syntax: '<angle>';
  initial-value: 0deg;
  inherits: false;
}

.box {
  --m: calc(.5*(var(--n) - 1));
  --dif-i: calc(var(--i) - var(--m));
  --dif-j: calc(var(--j) - var(--m));
  --abs-i: max(var(--dif-i), -1*var(--dif-i));
  --abs-j: max(var(--dif-j), -1*var(--dif-j));
  --sgn-i: clamp(-1, 2*var(--dif-i), 1);
  --sgn-j: clamp(-1, 2*var(--dif-j), 1);
  transform: scale(var(--sgn-i), var(--sgn-j));
  mask:
    repeating-conic-gradient(from var(--ang, 0deg), 
        red 0% 12.5%, transparent 0% 50%);
  animation: ang $t ease-in-out infinite;
  animation-delay: 
    calc(((var(--abs-i) + var(--abs-j))/var(--n) - 1)*#{$t});
}

@keyframes ang { to { --ang: .5turn; } }
Consumed by worms (live demo)

Time (and not only) formatting

Let’s say we have an element for which we store a number of seconds in a custom property, --val, and we want to display this in a mm:ss format, for example.

We use the floor of the ratio between --val and 60 (the number of seconds in a minute) to get the number of minutes and modulo for the number of seconds past that number of minutes. Then we use a clever little counter trick to display the formatted time in a pseudo-element.

@property --min {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false;
}

code {
  --min: calc(var(--val)/60 - .5);
  --sec: calc(var(--val) - var(--min)*60);
  counter-reset: min var(--min) sec var(--sec);
  
  &::after {
    /* so we get the time formatted as 02:09 */
    content: 
      counter(min, decimal-leading-zero) ':' 
      counter(sec, decimal-leading-zero);
  }
}

This works in most situations, but we encounter a problem when --val is exactly 0. In this case, 0/60 is 0 and then subtracting .5, we get -.5, which gets rounded to what’s the bigger adjacent integer in absolute value. That is, -1, not 0! This means our result will end up being -01:60, not 00:00!

Fortunately, we have a simple fix and that’s to slightly alter the formula for getting the number of minutes, --min:

--min: max(0, var(--val)/60 - .5);

There are other formatting options too, as illustrated below:

/* shows time formatted as 2:09 */
content: counter(min) ':' counter(sec, decimal-leading-zero);

/* shows time formatted as 2m9s */
content: counter(min) 'm' counter(sec) 's';

We can also apply the same technique to format the time as hh:mm:ss (live test).

@property --hrs {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false;
}

@property --min {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false;
}

code {
  --hrs: max(0, var(--val)/3600 - .5);
  --mod: calc(var(--val) - var(--hrs)*3600);
  --min: max(0, var(--mod)/60 - .5);
  --sec: calc(var(--mod) - var(--min)*60);
  counter-reset: hrs var(--hrs) var(--min) sec var(--sec);
  
  &::after {
    /* so we get the time formatted as 00:02:09 */
    content: 
      counter(hrs, decimal-leading-zero) ':' 
      counter(min, decimal-leading-zero) ':' 
      counter(sec, decimal-leading-zero);
  }
}

This is a technique I’ve used for styling the output of native range sliders such as the one below.

Screenshot showing a styled slider with a tooltip above the thumb indicating the elapsed time formatted as mm:ss. On the right of the slider, there's the remaining time formatted as -mm:ss.
Styled range input indicating time (live demo)

Time isn’t the only thing we can use this for. Counter values have to be integer values, which means the modulo trick also comes in handy for displaying decimals, as in the second slider seen below.

Screenshot showing three styled sliders withe second one having a tooltip above the thumb indicating the decimal value.
Styled range inputs, one of which has a decimal output (live demo)

A couple more such examples:

Screenshot showing multiple styled sliders with the third one being focused and showing a tooltip above the thumb indicating the decimal value.
Styled range inputs, one of which has a decimal output (live demo)
Screenshot showing two styled sliders with the second one being focused and showing a tooltip above the thumb indicating the decimal value.
Styled range inputs, one of which has a decimal output (live demo)

Even more use cases

Let’s say we have a volume slider with an icon at each end. Depending on the direction we move the slider’s thumb in, one of the two icons gets highlighted. This is possible by getting the absolute value, --abs, of the difference between each icon’s sign, --sgn-ico (-1 for the one before the slider, and 1 for the one after the slider), and the sign of the difference, --sgn-dir, between the slider’s current value, --val, and its previous value, --prv. If this is 0, then we’re moving in the direction of the current icon so we set its opacity to 1. Otherwise, we’re moving away from the current icon, so we keep its opacity at .15.

This means that, whenever the range input’s value changes, not only do we need to update its current value, --val, on its parent, but we need to update its previous value, which is another custom property, --prv, on the same parent wrapper:

addEventListener('input', e => {
  let _t = e.target, _p = _t.parentNode;
	
  _p.style.setProperty('--prv', +_p.style.getPropertyValue('--val'))
  _p.style.setProperty('--val', +_t.value)
})

The sign of their difference is the sign of the direction, --sgn-dir, we’re going in and the current icon is highlighted if its sign, --sgn-ico, and the sign of the direction we’re going in, --sgn-dir, coincide. That is, if the absolute value, --abs, of their difference is 0 and, at the same time, the parent wrapper is selected (it’s either being hovered or the range input in it has focus).

[role='group'] {
  --dir: calc(var(--val) - var(--prv));
  --sgn-dir: clamp(-1, var(--dir), 1);
  --sel: 0; /* is the slider focused or hovered? Yes 1/ No 0 */
  
  &:hover, &:focus-within { --sel: 1; }
}

.ico {
  --abs: max(var(--sgn-dir) - var(--sgn-ico), var(--sgn-ico) - var(--sgn-dir));
  --hlg: calc(var(--sel)*(1 - min(1, var(--abs)))); /* highlight current icon? Yes 1/ No 0 */
  opacity: calc(1 - .85*(1 - var(--hlg)));
}

Another use case is making property values of items on a grid depend on the parity of the sum of horizontal --abs-i and vertical --abs-j distances from the middle, --m. For example, let’s say we do this for the background-color:

@property --floor {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false;
}

.cell {
  --m: calc(.5*(var(--n) - 1));
  --abs-i: max(var(--m) - var(--i), var(--i) - var(--m));
  --abs-j: max(var(--m) - var(--j), var(--j) - var(--m));
  --sum: calc(var(--abs-i) + var(--abs-j));
  --floor: max(0, var(--sum)/2 - .5);
  --mod: calc(var(--sum) - var(--floor)*2);
  background: hsl(calc(90 + var(--mod)*180), 50%, 65%);
}
Screenshot showing a 16x16 grid where each tile is either lime or purple.
Background depending on parity of sum of horizontal and vertical distances to the middle (live demo)

We can spice things up by using the modulo 2 of the floor of the sum divided by 2:

@property --floor {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false;
}

@property --int {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false;
}

.cell {
  --m: calc(.5*(var(--n) - 1));
  --abs-i: max(var(--m) - var(--i), var(--i) - var(--m));
  --abs-j: max(var(--m) - var(--j), var(--j) - var(--m));
  --sum: calc(var(--abs-i) + var(--abs-j));
  --floor: max(0, var(--sum)/2 - .5);
  --int: max(0, var(--floor)/2 - .5);
  --mod: calc(var(--floor) - var(--int)*2);
  background: hsl(calc(90 + var(--mod)*180), 50%, 65%);
}
Screenshot showing a 16x16 grid where each tile is either lime or purple.
A more interesting variation of the previous demo (live demo)

We could also make both the direction of a rotation and that of a conic-gradient() depend on the same parity of the sum, --sum, of horizontal --abs-i and vertical --abs-j distances from the middle, --m. This is achieved by horizontally flipping the element if the sum, --sum, is even. In the example below, the rotation and size are also animated via Houdini (they both depend on a custom property, --f, which we register and then animate from 0 to 1), and so are the worm hue, --hue, and the conic-gradient() mask, both animations having a delay computed exactly as in previous examples.

@property --floor {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false;
}

.🐛 {
  --m: calc(.5*(var(--n) - 1));
  --abs-i: max(var(--m) - var(--i), var(--i) - var(--m));
  --abs-j: max(var(--m) - var(--j), var(--j) - var(--m));
  --sum: calc(var(--abs-i) + var(--abs-j));
  --floor: calc(var(--sum)/2 - .5);
  --mod: calc(var(--sum) - var(--floor)*2);
  --sgn: calc(2*var(--mod) - 1); /* -1 if --mod is 0; 1 id --mod is 1 */
  transform: 
    scalex(var(--sgn)) 
    scale(var(--f)) 
    rotate(calc(var(--f)*180deg));
  --hue: calc(var(--sgn)*var(--f)*360);
}
Grid wave: triangular rainbow worms (live demo).

Finally, another big use case for the techniques explained so far is shading not just convex, but also concave animated 3D shapes using absolutely no JavaScript! This is one topic that’s absolutely massive on its own and explaining everything would take an article as long as this one, so I won’t be going into it at all here. But I have made a few videos where I code a couple of such basic pure CSS 3D shapes (including a wooden star and a differently shaped metallic one) from scratch and you can, of course, also check out the CSS for the following example on CodePen.

Musical toy (live demo)


Using Absolute Value, Sign, Rounding and Modulo in CSS Today originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
https://css-tricks.com/using-absolute-value-sign-rounding-and-modulo-in-css-today/feed/ 6 320544
Platform News: Rounded Outlines, GPU-Accelerated SVG Animations, How CSS Variables Are Resolved https://css-tricks.com/platform-news-rounded-outlines-gpu-accelerated-svg-animations-how-css-variables-are-resolved/ https://css-tricks.com/platform-news-rounded-outlines-gpu-accelerated-svg-animations-how-css-variables-are-resolved/#comments Fri, 02 Apr 2021 19:35:28 +0000 https://css-tricks.com/?p=337636 In the news this week, Firefox gets rounded outlines, SVG animations are now GPU-accelerated in Chrome, there are no physical units in CSS, The New York Times crossword is accessible, and CSS variables are resolved before the value is inherited.…


Platform News: Rounded Outlines, GPU-Accelerated SVG Animations, How CSS Variables Are Resolved originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
In the news this week, Firefox gets rounded outlines, SVG animations are now GPU-accelerated in Chrome, there are no physical units in CSS, The New York Times crossword is accessible, and CSS variables are resolved before the value is inherited.

Let’s jump in the news!

Rounded outlines are coming to Firefox

The idea to have the outline follow the border curve has existed ever since it became possible to create rounded borders via the border-radius property in the mid 2000s. It was suggested to Mozilla, WebKit, and Chromium over ten years ago, and it’s even been part of the CSS UI specification since 2015:

The parts of the outline are not required to be rectangular. To the extent that the outline follows the border edge, it should follow the border-radius curve.

Fast-forward to today in 2021 and outlines are still rectangles in every browser without exception:

But this is finally starting to change. In a few weeks, Firefox will become the first browser with rounded outlines that automatically follow the border shape. This will also apply to Firefox’s default focus outline on buttons.

Three sets of round yellow buttons, comparing how Chrome, Firefox, and Safari handle outlines.

Please star Chromium Issue #81556 (sign in required) to help prioritize this bug and bring rounded outlines to Chrome sooner rather than later.

SVG animations are now GPU-accelerated in Chrome

Until recently, animating an SVG element via CSS would trigger repaint on every frame (usually 60 times per second) in Chromium-based browsers. Such constant repainting can have a negative impact on the smoothness of the animation and the performance of the page itself.

The latest version of Chrome has eliminated this performance issue by enabling hardware acceleration for SVG animations. This means that SVG animations are offloaded to the GPU and no longer run on the main thread.

Side by side comparison of the Performance tab in Chrome DevTools.
In this example, the SVG circle is continuously faded in and out via a CSS animation (see code)

The switch to GPU acceleration automatically made SVG animations more performant in Chromium-based browsers (Firefox does this too), which is definitely good news for the web:

Hooray for more screen reader-accessible, progressively enhanced SVG animations and less Canvas.

There cannot be real physical units in CSS

CSS defines six physical units, including in (inches) and cm (centimeters). Every physical unit is in a fixed ratio with the pixel unit, which is the canonical unit. For example, 1in is always exactly 96px. On most modern screens, this length does not correspond to 1 real-world inch.

The FAQ page of the CSS Working Group now answers the question why there can’t be real physical units in CSS. In short, the browser cannot always determine the exact size and resolution of the display (think projectors). For websites that need accurate real-world units, the Working Group recommends per-device calibration:

Have a calibration page, where you ask the user to measure the distance between two lines that are some CSS distance apart (say, 10cm), and input the value they get. Use this to find the scaling factor necessary for that screen (CSS length divided by user-provided length).

This scaling factor can then be set to a custom property and used to compute accurate lengths in CSS:

html {
  --unit-scale: 1.428;
}

.box {
  /* 5 real-world centimeters */
  width: calc(5cm * var(--unit-scale, 1));
}

The Times crossword is accessible to screen reader users

The NYT Open team wrote about some of the improvements to the New York Times website that have made it more accessible in recent years. The website uses semantic HTML (<article>, <nav>, etc.), increased contrast on important components (e.g., login and registration), and skip-to-content links that adapt to the site’s paywall.

Furthermore, the Games team made the daily crossword puzzle accessible to keyboard and screen reader users. The crossword is implemented as a grid of SVG <rect> elements. As the user navigates through the puzzle, the current square’s aria-label attribute (accessible name) is dynamically updated to provide additional context.

Screenshot of the crossword game with an open screen reader dialog announcing what is on the screen.
The screen reader announces the clue, the number of letters in the solution, and the position of the selected square

You can play the mini crossword without an account. Try solving the puzzle with the keyboard.

CSS variables are resolved before the value is inherited

Yuan Chuan recently shared a little CSS quiz that I didn’t answer correctly because I wasn’t sure if a CSS variable (the var() function) is resolved before or after the value is inherited. I’ll try to explain how this works on the following example:

html {
  --text-color: var(--main-color, black);
}

footer {
  --main-color: brown;
}

p {
  color: var(--text-color);
}

The question: Is the color of the paragraph in the footer black or brown? There are two possibilities. Either (A) the declared values of both custom properties are inherited to the paragraph, and then the color property resolves to brown, or (B) the --text-color property resolves to black directly on the <html> element, and then this value is inherited to the paragraph and assigned to the color property.

Two CSS rulesets, one as Option A and the other as Option B, both showing how variables are inherited and resolved between elements.

The correct answer is option B (the color is black). CSS variables are resolved before the value is inherited. In this case, --text-color falls back to black because --main-color does not exist on the <html> element. This rule is specified in the CSS Variables module:

It is important to note that custom properties resolve any var() functions in their values at computed-value time, which occurs before the value is inherited.


Platform News: Rounded Outlines, GPU-Accelerated SVG Animations, How CSS Variables Are Resolved originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
https://css-tricks.com/platform-news-rounded-outlines-gpu-accelerated-svg-animations-how-css-variables-are-resolved/feed/ 4 337636
When Sass and New CSS Features Collide https://css-tricks.com/when-sass-and-new-css-features-collide/ https://css-tricks.com/when-sass-and-new-css-features-collide/#comments Mon, 29 Jun 2020 14:56:00 +0000 https://css-tricks.com/?p=312771 Recently, CSS has added a lot of new cool features such as custom properties and new functions. While these things can make our lives a lot easier, they can also end up interacting with preprocessors, like Sass, in funny …


When Sass and New CSS Features Collide originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
Recently, CSS has added a lot of new cool features such as custom properties and new functions. While these things can make our lives a lot easier, they can also end up interacting with preprocessors, like Sass, in funny ways.

So this is going to be a post about the issues I’ve encountered, how I go around them, and why I still find Sass necessary these days.

The errors

If you’ve played with the new min() and max() functions, you may have ran into an error message like this when working with different units: “Incompatible units: vh and em.”

Screenshot. Shows the `Incompatible units: 'em' and 'vh'` error when trying to set `width: min(20em, 50vh)`.
An error when working with different types of units in the min()/ max() function

This is because Sass has its ownmin() function, and ignores the CSS min() function. Plus, Sass cannot perform any sort of computation using two values with units that don’t have a fixed relation between them.

For example, cm and in units have a fixed relation between them, so Sass can figure out what’s the result of min(20in, 50cm) and doesn’t throw an error when we try to use it in our code.

The same things goes for other units. Angular units, for example, all have a fixed relation between them: 1turn, 1rad or 1grad always compute to the same deg values. Same goes for 1s which is always 1000ms, 1kHz which is always 1000Hz, 1dppx which is always 96dpi, and 1in which is always 96px. This is why Sass can convert between them and mix them in computations and inside functions such as its own min() function.

But things break when these units don’t have a fixed relation between them (like the earlier case with em and vh units).

And it’s not just different units. Trying to use calc() inside min() also results in an error. If I try something like calc(20em + 7px), the error I get is, “calc(20em + 7px) is not a number for min.”

Screenshot. Shows the `'calc(20em + 7px)' is not a number for 'min'` error when trying to set `width: min(calc(20em + 7px), 50vh)`.
An error when using different unit values with calc() nested in the min()function

Another problem arises when we want to use a CSS variable or the result of a mathematical CSS function (such as calc(), min() or max()) in a CSS filter like invert().

In this case, we get told that “$color: 'var(--p, 0.85) is not a color for invert.”

Screenshot. Shows the `$color: 'var(--p, 0.85)' is not a color for 'invert'` error when trying to set `filter: invert(var(--p, .85))`.
var() in filter: invert() error

The same thing happens for grayscale(): “$color: ‘calc(.2 + var(--d, .3))‘ is not a color for grayscale.”

Screenshot. Shows the `$color: 'calc(.2 + var(--d, .3))' is not a color for 'grayscale'` error when trying to set `filter: grayscale(calc(.2 + var(--d, .3)))`.
calc() in filter: grayscale() error

opacity() causes the same issue: “$color: ‘var(--p, 0.8)‘ is not a color for opacity.”

Screenshot. Shows the `$color: 'var(--p, 0.8)' is not a color for 'opacity'` error when trying to set `filter: opacity(var(--p, 0.8))`.
var() in filter: opacity() error

However, other filter functions — including sepia(), blur(), drop-shadow(), brightness(), contrast() and hue-rotate()— all work just fine with CSS variables!

Turns out that what’s happening is similar to the min() and max() problem. Sass doesn’t have built-in sepia(), blur(), drop-shadow(), brightness(), contrast(), hue-rotate() functions, but it does have its own grayscale(), invert() and opacity() functions, and their first argument is a $color value. Since it doesn’t find that argument, it throws an error.

For the same reason, we also run into trouble when trying to use a CSS variable that lists at least two hsl()or hsla() values.

Screenshot. Shows the `wrong number of arguments (2 for 3) for 'hsl'` error when trying to set `color: hsl(9, var(--sl, 95%, 65%))`.
var() in color: hsl() error.

On the flip side, color: hsl(9, var(--sl, 95%, 65%)) is perfectly valid CSS and works just fine without Sass.

The exact same thing happens with the rgb()and rgba() functions.

Screenshot. Shows the `$color: 'var(--rgb, 128, 64, 64)' is not a color for 'rgba'` error when trying to set `color: rgba(var(--rgb, 128, 64, 64), .7)`.
var() in color: rgba() error.

Furthermore, if we import Compass and try to use a CSS variable inside a linear-gradient() or inside a radial-gradient(), we get another error, even though using variables inside conic-gradient() works just fine (that is, if the browser supports it).

Screenshot. Shows the At least two color stops are required for a linear-gradient error when trying to set background: linear-gradient(var(--c, pink), gold).
var() in background: linear-gradient() error.

This is because Compass comes with linear-gradient() and radial-gradient() functions, but has never added a conic-gradient() one.

The problems in all of these cases arise from Sass or Compass having identically-named functions and assuming those are what we intended to use in our code.

Drat!

The solution

The trick here is to remember that Sass is case-sensitive, but CSS isn’t.

That means we can write Min(20em, 50vh)and Sass won’t recognize it as its own min() function. No errors will be thrown and it’s still valid CSS that works as intended. Similarly, writing HSL()/ HSLA()/ RGB()/ RGBA() or Invert() allows us to avoid issues we looked at earlier.

As for gradients, I usually prefer linear-Gradient() and radial-Gradient() just because it’s closer to the SVG version, but using at least one capital letter in there works just fine.

But why?

Almost every time I tweet anything Sass-related, I get lectured on how it shouldn’t be used now that we have CSS variables. I thought I’d address that and explain why I disagree.

First, while I find CSS variables immensely useful and have used them for almost everything for the past three years, it’s good to keep in mind that they come with a performance cost and that tracing where something went wrong in a maze of calc() computations can be a pain with our current DevTools. I try not to overuse them to avoid getting into a territory where the downsides of using them outweigh the benefits.

Screenshot. Shows how `calc()` expressions are presented in DevTools.
Not exactly easy to figure out what’s the result of those calc() expressions.

In general, if it acts like a constant, doesn’t change element-to-element or state-to-state (in which case custom properties are definitely the way to go) or reduce the amount of compiled CSS (solving the repetition problem created by prefixes), then I’m going to use a Sass variable.

Secondly, variables have always been a pretty small portion of why I use Sass. When I started using Sass in late 2012, it was primarily for looping, a feature we still don’t have in CSS. While I’ve moved some of that looping to an HTML preprocessor (because it reduces the generated code and avoids having to modify both the HTML and the CSS later), I still use Sass loops in plenty of cases, like generating lists of values, stop lists inside gradient functions, lists of points inside a polygon function, lists of transforms, and so on.

Here’s an example. I used to generate n HTML items with a preprocessor. The choice of preprocessor matters less, but I’ll be using Pug here.

- let n = 12;

while n--
  .item

Then I would set the $n variable into the Sass (and it would have to be equal to that in the HTML) and loop up to it to generate the transforms that would position each item:

$n: 12;
$ba: 360deg/$n;
$d: 2em;

.item {
  position: absolute;
  top: 50%; left: 50%;
  margin: -.5*$d;
  width: $d; height: $d;
  /* prettifying styles */

  @for $i from 0 to $n {
    &:nth-child(#{$i + 1}) {
      transform: rotate($i*$ba) translate(2*$d) rotate(-$i*$ba);
			
      &::before { content: '#{$i}' }
    }
  }
}

However, this meant that I would have to change both the Pug and the Sass when changing the number of items, making the generated code very repetitive.

Screenshot. Shows the generated CSS, really verbose, almost completely identical transform declaration repeated for each item.
CSS generated by the above code

I have since moved to making Pug generate the indices as custom properties and then use those in the transform declaration.

- let n = 12;

body(style=`--n: ${n}`)
  - for(let i = 0; i < n; i++)
    .item(style=`--i: ${i}`)
$d: 2em;

.item {
  position: absolute;
  top: 50%;
  left: 50%;
  margin: -.5*$d;
  width: $d;
  height: $d;
  /* prettifying styles */
  --az: calc(var(--i)*1turn/var(--n));
  transform: rotate(var(--az)) translate(2*$d) rotate(calc(-1*var(--az)));
  counter-reset: i var(--i);
	
  &::before { content: counter(i) }
}

This significantly reduces the generated code.

Screenshot. Shows the generated CSS, much more compact, no having almost the exact same declaration set on every element separately.
CSS generated by the above code

However, looping in Sass is still necessary if I want to generate something like a rainbow.

@function get-rainbow($n: 12, $sat: 90%, $lum: 65%) {
  $unit: 360/$n;
  $s-list: ();
	
  @for $i from 0 through $n {
    $s-list: $s-list, hsl($i*$unit, $sat, $lum)
  }
	
  @return $s-list
}

html { background: linear-gradient(90deg, get-rainbow()) }

Sure, I could generate it as a list variable from Pug, but doing so doesn’t take advantage of the dynamic nature of CSS variables and it doesn’t reduce the amount of code that gets served to the browser, so there’s no benefit coming out of it.

Another big part of my Sass (and Compass) use is tied to built-in mathematical functions (such as trigonometric functions), which are part of the CSS spec now, but not yet implemented in any browser. Sass doesn’t come with these functions either, but Compass does and this is why I often need to use Compass.

And, sure, I could write my own such functions in Sass. I did resort to this in the beginning, before Compass supported inverse trigonometric functions. I really needed them, so I wrote my own based on the Taylor series. But Compass provides these sorts of functions nowadays and they are better and more performant than mine.

Mathematical functions are extremely important for me as I’m a technician, not an artist. The values in my CSS usually result from mathematical computations. They’re not magic numbers or something used purely for aesthetics. A example is generating lists of clip paths points that create regular or quasi-regular polygons. Think about the case where we want to create things like non-rectangular avatars or stickers.

Let’s consider a regular polygon with vertices on a circle with a radius 50% of the square element we start from. Dragging the slider in the following demo allows us to see where the points are placed for different numbers of vertices:

Putting it into Sass code, we have:

@mixin reg-poly($n: 3) {
  $ba: 360deg/$n; // base angle
  $p: (); // point coords list, initially empty
	
  @for $i from 0 to $n {
    $ca: $i*$ba; // current angle
    $x: 50%*(1 + cos($ca)); // x coord of current point
    $y: 50%*(1 + sin($ca)); // y coord of current point
    $p: $p, $x $y // add current point coords to point coords list
  }
	
  clip-path: polygon($p) // set clip-path to list of points
}

Note that here we’re also making use of looping and of things such as conditionals and modulo that are a real pain when using CSS without Sass.

A slightly more evolved version of this might involve rotating the polygon by adding the same offset angle ($oa) to the angle of each vertex. This can be seen in the following demo. This example tosses in a star mixin that works in a similar manner, except we always have an even number of vertices and every odd-indexed vertex is situated on a circle of a smaller radius ($f*50%, where $f is sub-unitary):

We can also have chubby stars like this:

Or stickers with interesting border patterns. In this particular demo, each sticker is created with a single HTML element and the border pattern is created with clip-path, looping and mathematics in Sass. Quite a bit of it, in fact.

Another example are these card backgrounds where looping, the modulo operation and exponential functions work together to generate the dithering pixel background layers:

This demo just happens to rely heavily on CSS variables as well.

Then there’s using mixins to avoid writing the exact same declarations over and over when styling things like range inputs. Different browsers use different pseudo-elements to style the components of such a control, so for every component, we have to set the styles that control its look on multiple pseudos.

Sadly, as tempting as it may be to put this in our CSS:

input::-webkit-slider-runnable-track, 
input::-moz-range-track, 
input::-ms-track { /* common styles */ }

…we cannot do it because it doesn’t work! The entire rule set is dropped if even one of the selectors isn’t recognized. And since no browser recognises all three of the above, the styles don’t get applied in any browser.

We need to have something like this if we want our styles to be applied:

input::-webkit-slider-runnable-track { /* common styles */ }
input::-moz-range-track { /* common styles */ }
input::-ms-track { /* common styles */ }

But that can mean a lot of identical styles repeated three times. And if we want to change, say, the background of the track, we need to change it in the ::-webkit-slider-runnable-track styles, in the ::-moz-range-track styles and in the ::-ms-track styles.

The only sane solution we have is to use a mixin. The styles get repeated in the compiled code because they have to be repeated there, but we don’t have to write the same thing three times anymore.

@mixin track() { /* common styles */ }

input {
  &::-webkit-slider-runnable-track { @include track }
  &::-moz-range-track { @include track }
  &::-ms-track { @include track }
}

The bottom line is: yes, Sass is still very much necessary in 2020.


When Sass and New CSS Features Collide originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
https://css-tricks.com/when-sass-and-new-css-features-collide/feed/ 16 312771
Multi-Thumb Sliders: General Case https://css-tricks.com/multi-thumb-sliders-general-case/ https://css-tricks.com/multi-thumb-sliders-general-case/#comments Wed, 08 Jan 2020 16:11:49 +0000 https://css-tricks.com/?p=300428

The first part of this two-part series detailed how we can get a two-thumb slider. Now we’ll look at a general multi-thumb case, but with a different and better technique for creating the fills in between the thumbs. And finally, …


Multi-Thumb Sliders: General Case originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>

The first part of this two-part series detailed how we can get a two-thumb slider. Now we’ll look at a general multi-thumb case, but with a different and better technique for creating the fills in between the thumbs. And finally, we’ll dive into the how behind the styling a realistic 3D-looking slider and a flat one.

Article Series:

  1. Multi-Thumb Sliders: Particular Two-Thumb Case
  2. Multi-Thumb Sliders: General Case (This Post)

A better, more flexible approach

Let’s say that, on a wrapper pseudo-element that covers the same area as the range inputs, we stack left-to–right linear-gradient() layers corresponding to each thumb. Each gradient layer is fully opaque (i.e. the alpha is 1) from the track minimum up to the thumb’s mid-line, after which it’s fully transparent (i.e. the alpha is 0).

Note that the RGB values don’t matter because all we care about are the alpha values. I personally use the red (for the fully opaque part) and transparent keywords in the code because they do the job with the least amount of characters.

How do we compute the gradient stop positions where we go from fully opaque to fully transparent? Well, these positions are always situated between a thumb radius from the left edge and a thumb radius from the right edge, so they are within a range that’s equal to the useful width (the track width, minus the thumb diameter).

This means we first add a thumb radius.Then we compute the progress by dividing the difference between the current thumb’s position and the minimum to the difference (--dif) between the maximum and the minimum. This progress value is a number in the [0, 1] interval — that’s 0 when the current thumb position is at the slider’s minimum, and 1 when the current thumb position is at the slider’s maximum. To get where exactly along that useful width interval we are, we multiply this progress value with the useful width.

The position we’re after is the sum between these two length values: the thumb radius and how far we are across the useful width interval.

The demo below allows us to see how everything looks stacked up in the 2D view and how exactly the range inputs and the gradients on their parent’s pseudo-element get layered in the 3D view. It’s also interactive, so we can drag the slider thumbs and see how the corresponding fill (which is created by a gradient layer on its parent’s pseudo-element) changes.

See the Pen by thebabydino (@thebabydino) on CodePen.

The demo is best viewed in Chrome and Firefox.

Alright, but simply stacking these gradient layers doesn’t give us the result we’re after.

The solution here is to make these gradients mask layers and then XOR them (more precisely, in the case of CSS masks, this means to XOR their alphas).

If you need a refresher on how XOR works, here’s one: given two inputs, the output of this operation is 1 if the input values are different (one of them is 1 and the other one is 0) and 0 if the input values are identical (both of them are 0 or both of them are 1)

The truth table for the XOR operation looks as follows:

Inputs Output
A B
0 0 0
0 1 1
1 0 1
1 1 0

You can also play with it in the following interactive demo, where you can toggle the input values and see how the output changes:

See the Pen by thebabydino (@thebabydino) on CodePen.

In our case, the input values are the alphas of the gradient mask layers along the horizontal axis. XOR-ing multiple layers means doing so for the first two from the bottom, then XOR-ing the third from the bottom with the result of the previous XOR operation and so on. For our particular case of left-to-right gradients with an alpha equal to 1 up to a point (decided by the corresponding thumb value) and then 0, it looks as illustrated below (we start from the bottom and work our way up):

SVG illustration. Illustrates the process described in the following three paragraphs.
How we XOR the gradient layer alphas (Demo).

Where both layers from the bottom have an alpha of 1, the resulting layer we get after XOR-ing them has an alpha of 0. Where they have different alpha values, the resulting layer has an alpha of 1. Where they both have an alpha of 0, the resulting layer has an alpha of 0.

Moving up, we XOR the third layer with the resulting layer we got at the previous step. Where both these layers have the same alpha, the alpha of the layer that results from this second XOR operation is 0. Where they have different alphas, the resulting alpha is 1.

Similarly, we then XOR the fourth layer from the bottom with the layer resulting from the second stage XOR operation.

In terms of CSS, this means using the exclude value for the standard mask-composite and the xor value for the non-standard -webkit-mask-composite. (For a better understanding of mask compositing, check out the crash course.)

This technique gives us exactly the result we want while also allowing us to use a single pseudo-element for all the fills. It’s also a technique that works for any number of thumbs. Let’s see how we can put it into code!

In order to keep things fully flexible, we start by altering the Pug code such that it allows to add or remove a thumb and update everything else accordingly by simply adding or removing an item from an array of thumb objects, where every object contains a value and a label (which will be only for screen readers):

- let min = -50, max = 50;
- let thumbs = [
-   { val: -15, lbl: 'Value A' }, 
-   { val: 20, lbl: 'Value B' }, 
-   { val: -35, lbl: 'Value C' }, 
-   { val: 45, lbl: 'Value D' }
- ];
- let nv = thumbs.length;

.wrap(role='group' aria-labelledby='multi-lbl' 
      style=`${thumbs.map((c, i) => `--v${i}: ${c.val}`).join('; ')}; 
             --min: ${min}; --max: ${max}`)
  #multi-lbl Multi thumb slider:
    - for(let i = 0; i < nv; i++)
      label.sr-only(for=`v${i}`) #{thumbs[i].lbl}
      input(type='range' id=`v${i}` min=min value=thumbs[i].val max=max)
      output(for=`v${i}` style=`--c: var(--v${i})`)

In the particular case of these exact four values, the generated markup looks as follows:

<div class='wrap' role='group' aria-labelledby='multi-lbl' 
     style='--v0: -15; --v1: 20; --v2: -35; --v3: 45; --min: -50; --max: 50'>
  <div id='multi-lbl'>Multi thumb slider:</div>
  <label class='sr-only' for='v0'>Value A</label>
  <input type='range' id='v0' min='-50' value='-15' max='50'/>
  <output for='v0' style='--c: var(--v0)'></output>
  <label class='sr-only' for='v1'>Value B</label>
  <input type='range' id='v1' min='-50' value='20' max='50'/>
  <output for='v1' style='--c: var(--v1)'></output>
  <label class='sr-only' for='v2'>Value C</label>
  <input type='range' id='v2' min='-50' value='-35' max='50'/>
  <output for='v2' style='--c: var(--v2)'></output>
  <label class='sr-only' for='v3'>Value D</label>
  <input type='range' id='v3' min='-50' value='45' max='50'/>
  <output for='v3' style='--c: var(--v3)'></output>
</div>

We don’t need to add anything to the CSS or the JavaScript for this to give us a functional slider where the <output> values get updated as we drag the sliders. However, having four <output> elements while the wrapper’s grid still has two columns would break the layout. So, for now, we remove the row introduced for the <output> elements, position these elements absolutely and only make them visible when the corresponding <input> is focused. We also remove the remains of the previous solution that uses both pseudo-elements on the wrapper.

.wrap {
  /* same as before */
  grid-template-rows: max-content #{$h}; /* only 2 rows now */

  &::after {
    background: #95a;
    // content: ''; // don't display for now
    grid-column: 1/ span 2;
    grid-row: 3;
  }
}

input[type='range'] {
  /* same as before */
  grid-row: 2; /* last row is second row now */
}

output {
  color: transparent;
  position: absolute;
  right: 0;
	
  &::after {
    content: counter(c);
    counter-reset: c var(--c);
  }
}

We’ll be doing more to prettify the result later, but for now, here’s what we have:

See the Pen by thebabydino (@thebabydino) on CodePen.

Next, we need to get those thumb to thumb fills. We do this by generating the mask layers in the Pug and putting them in a --fill custom property on the wrapper.

//- same as before
- let layers = thumbs.map((c, i) => `linear-gradient(90deg, red calc(var(--r) + (var(--v${i}) - var(--min))/var(--dif)*var(--uw)), transparent 0)`);

.wrap(role='group' aria-labelledby='multi-lbl' 
  style=`${thumbs.map((c, i) => `--v${i}: ${c.val}`).join('; ')}; 
    --min: ${min}; --max: ${max};
    --fill: ${layers.join(', ')}`)
  // - same as before

The generated HTML for the particular case of four thumbs with these values can be seen below. Note that this gets altered automatically if we add or remove items from the initial array:

<div class='wrap' role='group' aria-labelledby='multi-lbl' 
  style='--v0: -15; --v1: 20; --v2: -35; --v3: 45; 
    --min: -50; --max: 50;
    --fill: 
      linear-gradient(90deg, 
        red calc(var(--r) + (var(--v0) - var(--min))/var(--dif)*var(--uw)), 
        transparent 0), 
      linear-gradient(90deg, 
        red calc(var(--r) + (var(--v1) - var(--min))/var(--dif)*var(--uw)), 
        transparent 0), 
      linear-gradient(90deg, 
        red calc(var(--r) + (var(--v2) - var(--min))/var(--dif)*var(--uw)), 
        transparent 0), 
      linear-gradient(90deg, 
        red calc(var(--r) + (var(--v3) - var(--min))/var(--dif)*var(--uw)), 
        transparent 0)'>
  <div id='multi-lbl'>Multi thumb slider:</div>
  <label class='sr-only' for='v0'>Value A</label>
  <input type='range' id='v0' min='-50' value='-15' max='50'/>
  <output for='v0' style='--c: var(--v0)'></output>
  <label class='sr-only' for='v1'>Value B</label>
  <input type='range' id='v1' min='-50' value='20' max='50'/>
  <output for='v1' style='--c: var(--v1)'></output>
  <label class='sr-only' for='v2'>Value C</label>
  <input type='range' id='v2' min='-50' value='-35' max='50'/>
  <output for='v2' style='--c: var(--v2)'></output>
  <label class='sr-only' for='v3'>Value D</label>
  <input type='range' id='v3' min='-50' value='45' max='50'/>
  <output for='v3' style='--c: var(--v3)'></output>
</div>

Note that this means we need to turn the Sass variables relating to dimensions into CSS variables and replace the Sass variables in the properties that use them:

.wrap {
  /* same as before */
  --w: 20em;
  --h: 4em;
  --d: calc(.5*var(--h));
  --r: calc(.5*var(--d));
  --uw: calc(var(--w) - var(--d));
  background: linear-gradient(0deg, #ccc var(--h), transparent 0);
  grid-template: max-content var(--h)/ var(--w);
  width: var(--w);
}

We set our mask Oo the wrapper’s ::after pseudo-element:

.wrap {
  /* same as before */
  
  &::after {
    content: '';
    background: #95a;
    grid-column: 1/ span 2;
    grid-row: 2;

    /* non-standard WebKit version */
    -webkit-mask: var(--fill);
    -webkit-mask-composite: xor;

    /* standard version, supported in Firefox */
    mask: var(--fill);
    mask-composite: exclude;
  }
}

Now we have exactly what we want and the really cool thing about this technique is that all we need to do to change the number of thumbs is add or remove thumb objects (with a value and a label for each) to the thumbs array in the Pug code — absolutely nothing else needs to change!

See the Pen by thebabydino (@thebabydino) on CodePen.

Prettifying tweaks

What we have so far is anything but a pretty sight. So let’s start fixing that!

Option #1: a realistic look

Let’s say we want to achieve the result below:

Screenshot. The track and fill are the same height as the thumbs. The track looks carved into the page, while the fill and the thumb have a convex look inside it.
The realistic look we’re after.

A first step would be to make the track the same height as the thumb and round the track ends. Up to this point, we’ve emulated the track with a background on the .wrap element. While it’s technically possible to emulate a track with rounded ends by using layered linear and radial gradients, it’s really not the best solution, especially when the wrapper still has a free pseudo-element (the ::before).

.wrap {
  /* same as before */
  --h: 2em;
  --d: var(--h);
  
  &::before, &::after {
    border-radius: var(--r);
    background: #ccc;
    content: '';
    grid-column: 1/ span 2;
    grid-row: 2;
  }
  
  &::after {
    background: #95a;

    /* non-standard WebKit version */
    -webkit-mask: var(--fill);
    -webkit-mask-composite: xor;

    /* standard version, supported in Firefox */
    mask: var(--fill);
    mask-composite: exclude;
  }
}

See the Pen by thebabydino (@thebabydino) on CodePen.

Using ::before to emulate the track opens up the possibility of getting a slightly 3D look:

<pre rel="SCSS"><code class="language-scss">.wrap {
  /* same as before */
  
  &::before, &::after {
    /* same as before */
    box-shadow: inset 0 2px 3px rgba(#000, .3);
  }
  
  &::after {
    /* same as before */
    background: 
      linear-gradient(rgba(#fff, .3), rgba(#000, .3))
      #95a;
  }
}

I’m by no means a designer, so those values could probably be tweaked for a better looking result, but we can already see a difference:

See the Pen by thebabydino (@thebabydino) on CodePen.

This leaves us with a really ugly thumb, so let’s fix that part as well!

We make use of the technique of layering multiple backgrounds with different background-clip (and background-origin) values.

@mixin thumb() {
  border: solid calc(.5*var(--r)) transparent;
  border-radius: 50%; /* make circular */
  box-sizing: border-box; /* different between Chrome & Firefox */
  /* box-sizing needed now that we have a non-zero border */
  background: 
    linear-gradient(rgba(#000, .15), rgba(#fff, .2)) content-box, 
    linear-gradient(rgba(#fff, .3), rgba(#000, .3)) border-box, 
    currentcolor;
  pointer-events: auto;
  width: var(--d); height: var(--d);
}

I’ve described this technique in a lot of detail in an older article. Make sure you check it out if you need a refresher!

The above bit of code would do close to nothing, however, if the currentcolor value is black (#000) which it is right now. Let’s fix that and also change the cursor on the thumbs to something more fitting:

input[type='range'] {
  /* same as before */
  color: #eee;
  cursor: grab;
  
  &:active { cursor: grabbing; }
}

The result is certainly more satisfying than before:

See the Pen by thebabydino (@thebabydino) on CodePen.

Something else that really bothers me is how close the label text is to the slider. We can fix this by introducing a grid-gap on the wrapper:

.wrap {
  /* same as before */
  grid-gap: .625em;
}

But the worst problem we still have are those absolutely positioned outputs in the top right corner. The best way to fix this is to introduce a third grid row for them and move them with the thumbs.

The position of the thumbs is computed in a similar manner to that of the sharp stops of the gradient layers we use for the fill mask.

Initially, we place the left edge of the outputs along the vertical line that’s a thumb radius --r away from the left edge of the slider. In order to middle align the outputs with this vertical line, we translate them back (to the left, in the negative direction of the x-axis, so we need a minus sign) by half of their width (50%, as percentage values in translate() functions are relative to the dimensions of the element the transform is applied to).

In order to move them with the thumbs, we subtract the minimum value (--min) from the current value of the corresponding thumb (--c), divide this difference by the difference (--dif) between the maximum value (--max) and the minimum value (--min). This gives us a progress value in the [0, 1] interval. We then multiply this value with the useful width (--uw), which describes the real range of motion.

.wrap {
  /* same as before */
  grid-template-rows: max-content var(--h) max-content;
}

output {
  background: currentcolor;
  border-radius: 5px;
  color: transparent;
  grid-column: 1;
  grid-row: 3;
  margin-left: var(--r);
  padding: 0 .375em;
  transform: translate(calc((var(--c) - var(--min))/var(--dif)*var(--uw) - 50%));
  width: max-content;
  
  &::after {
    color: #fff;
    content: counter(c);
    counter-reset: c var(--c);
  }
}

See the Pen by thebabydino (@thebabydino) on CodePen.

This looks much better at a first glance. However, a closer inspection reveals that we still have a bunch of problems.

The first one is that overflow: hidden cuts out a bit of the <output> elements when we get to the track end.

In order to fix this, we must understand what exactly overflow: hidden does. It cuts out everything outside an element’s padding-box, as illustrated by the interactive demo below, where you can click the code to toggle the CSS declaration.

See the Pen by thebabydino (@thebabydino) on CodePen.

This means a quick fix for this issue is to add a big enough lateral padding on the wrapper .wrap.

padding: 0 2em;

We’re styling our multi-thumb slider in isolation here, but, in reality, it probably won’t be the only thing on a page, so, if spacing is limited, we can invert that lateral padding with a negative lateral margin.

If the nearby elements still have the default have position: static, the fact that we’ve relatively positioned the wrapper should make the outputs go on top of what they overlap, otherwise, tweaking the z-index on the .wrap should do it.

The bigger problem is that this technique we’ve used results in some really weird-looking <output> overlaps when were dragging the thumbs.

Increasing the z-index when the <input> is focused on the corresponding <output> as well solves the particular problem of the <output> overlaps:

input[type='range'] {
  &:focus {
    outline: solid 0 transparent;
		
    &, & + output {
      color: darkorange;
      z-index: 2;
    }
  }
}

However, it does nothing for the underlying issue and this becomes obvious when we change the background on the body, particularly if we change it to an image one, as this doesn’t allow the <output> text to hide in it anymore:

See the Pen by thebabydino (@thebabydino) on CodePen.

This means we need to rethink how we hide the <output> elements in the normal state and how we reveal them in a highlight state, such as :focus. We also want to do this without bloating our CSS.

The solution is to use the technique I described about a year ago in the “DRY Switching with CSS Variables” article: use a highlight --hl custom property where the value is 0 in the normal state and 1 in a highlight state (:focus). We also compute its negation (--nothl).

* {
  --hl: 0;
  --nothl: calc(1 - var(--hl));
  margin: 0;
  font: inherit
}

As it is, this does nothing yet. The trick is to make all properties that we want to change in between the two states depend on --hl and, if necessary, its negation (code>–nothl).

$hlc: #f90;

@mixin thumb() {
  /* same as before */
  background-color: $hlc;
}

input[type='range'] {
  /* same as before */
  filter: grayScale(var(--nothl));
  z-index: calc(1 + var(--hl));
  
  &:focus {
    outline: solid 0 transparent;
    
    &, & + output { --hl: 1; }
  }
}

output {
  /* same grid placement */
  margin-left: var(--r);
  max-width: max-content;
  transform: translate(calc((var(--c) - var(--min))/var(--dif)*var(--uw)));
	
  &::after {
    /* same as before */
    background: 
      linear-gradient(rgba(#fff, .3), rgba(#000, .3))
      $hlc;
    border-radius: 5px;
    display: block;
    padding: 0 .375em;
    transform: translate(-50%) scale(var(--hl));
  }
}

See the Pen by thebabydino (@thebabydino) on CodePen.

We’re almost there! We can also add transitions on state change:

$t: .3s;

input[type='range'] {
  /* same as before */
  transition: filter $t ease-out;
}

output::after {
  /* same as before */
  transition: transform $t ease-out;
}

See the Pen by thebabydino (@thebabydino) on CodePen.

A final improvement would be to grayscale() the fill if none of the thumbs are focused. We can do this by using :focus-within on our wrapper:

.wrap {
  &::after {
    /* same as before */
    filter: Grayscale(var(--nothl));
    transition: filter $t ease-out;
  }
	
  &:focus-within { --hl: 1; }
}

And that’s it!

See the Pen by thebabydino (@thebabydino) on CodePen.

Option #2: A flat look

Let’s see how we can get a flat design. For example:

Screenshot. All slider components are flat, no shadows or gradients. The track and fill are narrower than the thumbs and middle aligned with these. The track has a striped background. The thumbs are scaled down and reveal circular holes in the track around them in their unfocused state.
The flat look we’re after.

The first step is to remove the box shadows and gradients that give our previous demo a 3D look and make the track background a repeating gradient.:

See the Pen by thebabydino (@thebabydino) on CodePen.

The size change of the thumb on :focus can be controlled with a scaling transform with a factor that depends on the highlight switch variable (--hl).

@mixin thumb() {
  /* same as before */
  transform: scale(calc(1 - .5*var(--nothl)));
  transition: transform $t ease-out;
}

See the Pen by thebabydino (@thebabydino) on CodePen.

But what about the holes in the track around the thumbs?

The mask compositing technique is extremely useful here. This involves layering radial gradients to create discs at every thumb position and, after we’re done with them, invert (i.e. compositing with a fully opaque layer) the result to turn those discs into holes.

SVG illustration. Shows that XOR-ing a bunch of radial-gradient() layers gives us a layer with opaque discs and everything else transparent and when, we xor this resulting layer with a fully opaque one, this fully opaque layer acts as an inverter, turning the discs into transparent holes in an otherwise fully opaque layer.
How we XOR the gradient layer alphas (Demo).

This means altering the Pug code a bit so that we’re generating the list of radial gradients that create the discs corresponding to each thumb. In turn, we’ll invert those in the CSS:

//- same as before
- let tpos = thumbs.map((c, i) => `calc(var(--r) + (var(--v${i}) - var(--min))/var(--dif)*var(--uw))`);
- let fill = tpos.map(c => `linear-gradient(90deg, red ${c}, transparent 0)`);
- let hole = tpos.map(c => `radial-gradient(circle at ${c}, red var(--r), transparent 0)`)

.wrap(role='group' aria-labelledby='multi-lbl' 
  style=`${thumbs.map((c, i) => `--v${i}: ${c.val}`).join('; ')}; 
    --min: ${min}; --max: ${max};
    --fill: ${fill.join(', ')}; 
    --hole: ${hole.join(', ')}`)
  // -same wrapper content as before

This generates the following markup:

<div class='wrap' role='group' aria-labelledby='multi-lbl' 
  style='--v0: -15; --v1: 20; --v2: -35; --v3: 45; 
    --min: -50; --max: 50;
    --fill: 
      linear-gradient(90deg, 
        red calc(var(--r) + (var(--v0) - var(--min))/var(--dif)*var(--uw)), 
        transparent 0), 
      linear-gradient(90deg, 
        red calc(var(--r) + (var(--v1) - var(--min))/var(--dif)*var(--uw)), 
        transparent 0), 
       linear-gradient(90deg, 
         red calc(var(--r) + (var(--v2) - var(--min))/var(--dif)*var(--uw)), 
         transparent 0), 
       linear-gradient(90deg, 
         red calc(var(--r) + (var(--v3) - var(--min))/var(--dif)*var(--uw)), 
         transparent 0); 
     --hole: 
       radial-gradient(circle 
         at calc(var(--r) + (var(--v0) - var(--min))/var(--dif)*var(--uw)), 
         red var(--r), transparent 0), 
       radial-gradient(circle 
         at calc(var(--r) + (var(--v1) - var(--min))/var(--dif)*var(--uw)), 
         red var(--r), transparent 0), 
       radial-gradient(circle 
         at calc(var(--r) + (var(--v2) - var(--min))/var(--dif)*var(--uw)), 
         red var(--r), transparent 0), 
       radial-gradient(circle 
         at calc(var(--r) + (var(--v3) - var(--min))/var(--dif)*var(--uw)), 
         red var(--r), transparent 0)'>
  <!-- same content as before -->
</div>

In the CSS, we set a mask on both pseudo-elements and give a different value for each one. We also XOR the mask layers on them.

In the case of ::before, the mask is the list of radial-gradient() discs XOR-ed with a fully opaque layer (which acts as an inverter to turn the discs into circular holes). For ::after, it’s the list of fill linear-gradient() layers.

.wrap {
  /* same as before */
  
  &::before, &::after {
    content: '';
    /* same as before */
    
    --mask: linear-gradient(red, red), var(--hole);

    /* non-standard WebKit version */
    -webkit-mask: var(--mask);
    -webkit-mask-composite: xor;

    /* standard version, supported in Firefox */
    mask: var(--mask);
    mask-composite: exclude;
  }
	
  &::after {
    background: #95a;
    --mask: var(--fill);
  }
}

See the Pen by thebabydino (@thebabydino) on CodePen.

The final step is to adjust the track, fill height, and middle align them vertically within their grid cell (along with the thumbs):

.wrap {
  /* same as before */
  
  &::before, &::after {
    /* same as before */
    align-self: center;
    height: 6px;
  }
}

We now have our desired flat multi-thumb slider!

See the Pen by thebabydino (@thebabydino) on CodePen.


Multi-Thumb Sliders: General Case originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
https://css-tricks.com/multi-thumb-sliders-general-case/feed/ 6 300428
Multi-Thumb Sliders: Particular Two-Thumb Case https://css-tricks.com/multi-thumb-sliders-particular-two-thumb-case/ Tue, 07 Jan 2020 15:51:44 +0000 https://css-tricks.com/?p=295854 This is a concept I first came across a few years back when Lea Verou wrote an article on it. Multi-range sliders have sadly been removed from the spec since, but something else that has happened in the meanwhile is …


Multi-Thumb Sliders: Particular Two-Thumb Case originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
This is a concept I first came across a few years back when Lea Verou wrote an article on it. Multi-range sliders have sadly been removed from the spec since, but something else that has happened in the meanwhile is that CSS got better — and so have I, so I recently decided to make my own 2019 version.

In this two-part article, we’ll go through the how, step-by-step, first building an example with two thumbs, then identify the issues with it. We’ll solve those issues, first for the two-thumb case then, in part two, come up with a better solution for the multi-thumb case.

Note how the thumbs can pass each other and we can have any possible order, with the fills in between the thumbs adapting accordingly. Surprisingly, the entire thing is going to require extremely little JavaScript.

Article Series:

  1. Multi-Thumb Sliders: Particular Two-Thumb Case (This Post)
  2. Multi-Thumb Sliders: General Case

Basic structure

We need two range inputs inside a wrapper. They both have the same minimum and maximum value (this is very important because nothing is going to work properly otherwise), which we set as custom properties on the wrapper (--min and --max). We also set their values as custom properties (--a and --b).

- let min = -50, max = 50
- let a = -30, b = 20;

.wrap(style=`--a: ${a}; --b: ${b}; --min: ${min}; --max: ${max}`)
  input#a(type='range' min=min value=a max=max)
  input#b(type='range' min=min value=b max=max)

This generates the following markup:

<div class='wrap' style='--a: -30; --b: 20; --min: -50; --max: 50'>
  <input id='a' type='range' min='-50' value='-30' max='50'/>
  <input id='b' type='range' min='-50' value='20' max='50'/>
</div>

Accessibility considerations

We have two range inputs and they should probably each have a <label>, but we want our multi-thumb slider to have a single label. How do we solve this issue? We can make the wrapper a <fieldset>, use its <legend> to describe the entire multi-thumb slider, and have a <label> that’s only visible to screen readers for each of our range inputs. (Thanks to Zoltan for this great suggestion.)

But what if we want to have a flex or grid layout on our wrapper? That’s something we probably want, as the only other option is absolute positioning and that comes with its own set of issues. Then we run into a Chromium issue where <fieldset> cannot be a flex or grid container.

To go around this, we use the following ARIA equivalent (which I picked up from this post by Steve Faulkner):

- let min = -50, max = 50
- let a = -30, b = 20;

.wrap(role='group' aria-labelledby='multi-lbl' style=`--a: ${a}; --b: ${b}; --min: ${min}; --max: ${max}`)
  #multi-lbl Multi thumb slider:
  label.sr-only(for='a') Value A:
  input#a(type='range' min=min value=a max=max)
  label.sr-only(for='b') Value B:
  input#b(type='range' min=min value=b max=max)

The generated markup is now:

<div class='wrap' role='group' aria-labelledby='multi-lbl' style='--a: -30; --b: 20; --min: -50; --max: 50'>
  <div id='multi-lbl'>Multi thumb slider:</div>
  <label class='sr-only' for='a'>Value A:</label>
  <input id='a' type='range' min='-50' value='-30' max='50'/>
  <label class='sr-only' for='b'>Value B:</label>
  <input id='b' type='range' min='-50' value='20' max='50'/>
</div>

If we set an aria-label or an aria-labelledby attribute on an element, we also need to give it a role.

Basic styling

We make the wrapper a middle-aligned grid with two rows and one column. The bottom grid cell gets the dimensions we want for the slider, while the top one gets the same width as the slider, but can adjust its height according to the group label’s content.

$w: 20em;
$h: 1em;

.wrap {
  display: grid;
  grid-template-rows: max-content $h;
  margin: 1em auto;
  width: $w;
}

To visually hide the <label> elements, we absolutely position them and clip them to nothing:

.wrap {
  // same as before
  overflow: hidden; // in case <label> elements overflow
  position: relative;
}

.sr-only {
  position: absolute;
  clip-path: inset(50%);
}

Some people might shriek about clip-path support, like how using it cuts out pre-Chromium Edge and Internet Explorer, but it doesn’t matter in this particular case! We’re getting to the why behind that in a short bit.

We place the sliders, one on top of the other, in the bottom grid cell:

input[type='range'] {
  grid-column: 1;
  grid-row: 2;
}

See the Pen by thebabydino (@thebabydino) on CodePen.

We can already notice a problem however: not only does the top slider track show up above the thumb of the bottom one, but the top slider makes it impossible for us to even click and interact with the bottom one using a mouse or touch.

In order to fix this, we remove any track backgrounds and borders and highlight the track area by setting a background on the wrapper instead. We also set pointer-events: none on the actual <input> elements and then revert to auto on their thumbs.

@mixin track() {
  background: none; /* get rid of Firefox track background */
  height: 100%;
  width: 100%;
}

@mixin thumb() {
  background: currentcolor;
  border: none; /* get rid of Firefox thumb border */
  border-radius: 0; /* get rid of Firefox corner rounding */
  pointer-events: auto; /* catch clicks */
  width: $h; height: $h;
}

.wrap {
  /* same as before */
  background: /* emulate track with wrapper background */ 
    linear-gradient(0deg, #ccc $h, transparent 0);
}

input[type='range'] {
  &::-webkit-slider-runnable-track, 
  &::-webkit-slider-thumb, & { -webkit-appearance: none; }
  
  /* same as before */
  background: none; /* get rid of white Chrome background */
  color: #000;
  font: inherit; /* fix too small font-size in both Chrome & Firefox */
  margin: 0;
  pointer-events: none; /* let clicks pass through */
  
  &::-webkit-slider-runnable-track { @include track; }
  &::-moz-range-track { @include track; }
  
  &::-webkit-slider-thumb { @include thumb; }
  &::-moz-range-thumb { @include thumb; }
}

Note that we’ve set a few more styles on the input itself as well as on the track and thumb in order to make the look consistent across the browsers that support letting clicks pass through the actual input elements and their tracks, while allowing them on the thumbs. This excludes pre-Chromium Edge and IE, which is why we haven’t included the -ms- prefix — there’s no point styling something that wouldn’t be functional in these browsers anyway. This is also why we can use clip-path to hide the <label> elements.

If you’d like to know more about default browser styles in order to understand what’s necessary to override here, you can check out this article where I take an in-depth look at range inputs (and where I also detail the reasoning behind using mixins here).

See the Pen by thebabydino (@thebabydino) on CodePen.

Alright, we now have something that looks functional. But in order to really make it functional, we need to move on to the JavaScript!

Functionality

The JavaScript is pretty straightforward. We need to update the custom properties we’ve set on the wrapper. (For an actual use case, they’d be set higher up in the DOM so that they’re also inherited by the elements whose styles that depend on them.)

addEventListener('input', e => {
  let _t = e.target;
  _t.parentNode.style.setProperty(`--${_t.id}`, +_t.value)
}, false);

See the Pen by thebabydino (@thebabydino) on CodePen.

However, unless we bring up DevTools to see that the values of those two custom properties really change in the style attribute of the wrapper .wrap, it’s not really obvious that this does anything. So let’s do something about that!

Showing values

Something we can do to make it obvious that dragging the thumbs actually changes something is to display the current values. In order to do this, we use an output element for each input:

- let min = -50, max = 50
- let a = -30, b = 20;

.wrap(role='group' aria-labelledby='multi-lbl' style=`--a: ${a}; --b: ${b}; --min: ${min}; --max: ${max}`)
  #multi-lbl Multi thumb slider:
  label.sr-only(for='a') Value A:
  input#a(type='range' min=min value=a max=max)
  output(for='a' style='--c: var(--a)')
  label.sr-only(for='b') Value B:
  input#b(type='range' min=min value=b max=max)
  output(for='b' style='--c: var(--b)')

The resulting HTML looks as follows:

<div class='wrap' role='group' aria-labelledby='multi-lbl' style='--a: -30; --b: 20; --min: -50; --max: 50'>
  <div id='multi-lbl'>Multi thumb slider:</div>
  <label class='sr-only' for='a'>Value A:</label>
  <input id='a' type='range' min='-50' value='-30' max='50'/>
  <output for='a' style='--c: var(--a)'></output>
  <label class='sr-only' for='b'>Value B:</label>
  <input id='b' type='range' min='-50' value='20' max='50'/>
  <output for='b' style='--c: var(--b)'></output>
</div>

We display the values in an ::after pseudo-element using a little counter trick:

output {
  &::after {
    counter-reset: c var(--c);
    content: counter(c);
  }
}

See the Pen by thebabydino (@thebabydino) on CodePen.

It’s now obvious these values change as we drag the sliders, but the result is ugly and it has messed up the wrapper background alignment, so let’s add a few tweaks! We could absolutely position the <output> elements, but for now, we simply squeeze them in a row between the group label and the sliders:

.wrap {
  // same as before
  grid-template: repeat(2, max-content) #{$h}/ 1fr 1fr;
}

[id='multi-lbl'] { grid-column: 1/ span 2 }

input[type='range'] {
  // same as before
  grid-column: 1/ span 2;
  grid-row: 3;
}

output {
  grid-row: 2;
  
  &:last-child { text-align: right; }
  
  &::after {
    content: '--' attr(for) ': ' counter(c) ';'
    counter-reset: c var(--c);
  }
}

Much better!

See the Pen by thebabydino (@thebabydino) on CodePen.

Setting separate :focus styles even gives us something that doesn’t look half bad, plus allows us to see which value we’re currently modifying.

input[type='range'] {
  /* same as before */
  z-index: 1;

  &:focus {
    z-index: 2;
    outline: dotted 1px currentcolor;
    
    &, & + output { color: darkorange }
  }
}

See the Pen by thebabydino (@thebabydino) on CodePen.

All we need now is to create the fill between the thumbs.

The tricky part

We can recreate the fill with an ::after pseudo-element on the wrapper, which we place on the bottom grid row where we’ve also placed the range inputs. This pseudo-element comes, as the name suggests, after the inputs, but it will still show up underneath them because we’ve set positive z-index values on them. Note that setting the z-index works on the inputs (without explicitly setting their position to something different from static) because they’re grid children.

The width of this pseudo-element should be proportional to the difference between the higher input value and the lower input value. The big problem here is that they pass each other and we have no way of knowing which has the higher value.

First approach

My first idea on how to solve this was by using width and min-width together. In order to better understand how this works, consider that we have two percentage values, --a and --b, and we want to make an element’s width be the absolute value of the difference between them.

Either one of the two values can be the bigger one, so we pick an example where --b is bigger and an example where --a is bigger:

<div style='--a: 30%; --b: 50%'><!-- first example, --b is bigger --></div>
<div style='--a: 60%; --b: 10%'><!-- second example, --a is bigger --></div>

We set width to the second value (--b) minus the first (--a) and min-width to the first value (--a) minus the second one (--b).

div {
  background: #f90;
  height: 4em;
  min-width: calc(var(--a) - var(--b));
  width: calc(var(--b) - var(--a));
}

If the second value (--b) is bigger, then the width is positive (which makes it valid) and the min-width negative (which makes it invalid). That means the computed value is the one set via the width property. This is the case in the first example, where --b is 70% and --a is 50%. That means the width computes to 70% - 50% = 20%, while the min-width computes to 50% - 70% = -20%.

If the first value is bigger, then the width is negative (which makes it invalid) and the min-width is positive (which makes it valid), meaning the computed value is that set via the min-width property. This is the case in the second example, where --a is 80% and --b is 30%, meaning the width computes to 30% - 80% = -50%, while the min-width computes to 80% - 30% = 50%.

See the Pen by thebabydino (@thebabydino) on CodePen.

Applying this solution for our two thumb slider, we have:

.wrap {
  /* same as before */
  --dif: calc(var(--max) - var(--min));
  
  &::after {
    content: '';
    background: #95a;
    grid-column: 1/ span 2;
    grid-row: 3;
    min-width: calc((var(--a) - var(--b))/var(--dif)*100%);
    width: calc((var(--b) - var(--a))/var(--dif)*100%);
  }
}

In order to represent the width and min-width values as percentages, we need to divide the difference between our two values by the difference (--dif) between the maximum and the minimum of the range inputs and then multiply the result we get by 100%.

See the Pen by thebabydino (@thebabydino) on CodePen.

So far, so good… so what?

The ::after always has the right computed width, but we also need to offset it from the track minimum by the smaller value and we can’t use the same trick for its margin-left property.

My first instinct here was to use left, but actual offsets don’t work on their own. We’d have to also explicitly set position: relative on our ::after pseudo-element in order to make it work. I felt kind of meh about doing that, so I opted for margin-left instead.

The question is what approach can we take for this second property. The one we’ve used for the width doesn’t work because there is no such thing as min-margin-left.

A min() function is now in the CSS spec, but at the time when I coded these multi-thumb sliders, it was only implemented by Safari (it has since landed in Chrome as well). Safari-only support was not going to cut it for me since I don’t own any Apple device or know anyone in real life who does… so I couldn’t play with this function! And not being able to come up with a solution I could actually test meant having to change the approach.

Second approach

This involves using both of our wrapper’s (.wrap) pseudo-elements: one pseudo-element’s margin-left and width being set as if the second value is bigger, and the other’s set as if the first value is bigger.

With this technique, if the second value is bigger, the width we’re setting on ::before is positive and the one we’re setting on ::after is negative (which means it’s invalid and the default of 0 is applied, hiding this pseudo-element). Meanwhile, if the first value is bigger, then the width we’re setting on ::before is negative (so it’s this pseudo-element that has a computed width of 0 and is not being shown in this situation) and the one we’re setting on ::after is positive.

Similarly, we use the first value (--a) to set the margin-left property on the ::before since we assume the second value --b is bigger for this pseudo-element. That means --a is the value of the left end and --b the value of the right end.

For ::after, we use the second value (--b) to set the margin-left property, since we assume the first value --a is bigger this pseudo-element. That means --b is the value of the left end and --a the value of the right end.

Let’s see how we put it into code for the same two examples we previously had, where one has --b bigger and another where --a is bigger:

<div style='--a: 30%; --b: 50%'></div>
<div style='--a: 60%; --b: 10%'></div>
div {
  &::before, &::after {
    content: '';
    height: 5em;
  }
  
  &::before {
    margin-left: var(--a);
    width: calc(var(--b) - var(--a));
  }

  &::after {
    margin-left: var(--b);
    width: calc(var(--a) - var(--b));
  }
}

See the Pen by thebabydino (@thebabydino) on CodePen.

Applying this technique for our two thumb slider, we have:

.wrap {
  /* same as before */
  --dif: calc(var(--max) - var(--min));
  
  &::before, &::after {
    grid-column: 1/ span 2;
    grid-row: 3;
    height: 100%;
    background: #95a;
    content: ''
  }
  
  &::before {
    margin-left: calc((var(--a) - var(--min))/var(--dif)*100%);
    width: calc((var(--b) - var(--a))/var(--dif)*100%)
  }
  
  &::after {
    margin-left: calc((var(--b) - var(--min))/var(--dif)*100%);
    width: calc((var(--a) - var(--b))/var(--dif)*100%)
  }
}

See the Pen by thebabydino (@thebabydino) on CodePen.

We now have a nice functional slider with two thumbs. But this solution is far from perfect.

Issues

The first issue is that we didn’t get those margin-left and width values quite right. It’s just not noticeable in this demo due to the thumb styling (such as its shape, dimensions relative to the track, and being full opaque).

But let’s say our thumb is round and maybe even smaller than the track height:

See the Pen by thebabydino (@thebabydino) on CodePen.

We can now see what the problem is: the endlines of the fill don’t coincide with the vertical midlines of the thumbs.

This is because of the way moving the thumb end-to-end works. In Chrome, the thumb’s border-box moves within the limits of the track’s content-box, while in Firefox, it moves within the limits of the slider’s content-box. This can be seen in the recordings below, where the padding is transparent, while the content-box and the border are semi-transparent. We’ve used orange for the actual slider, red for the track and purple for the thumb.

Animated gif. Chrome only moves the thumb within the left and right limits of the track's content-box.
Recording of the thumb motion in Chrome from one end of the slider to the other.

Note that the track’s width in Chrome is always determined by that of the parent slider – any width value we may set on the track itself gets ignored. This is not the case in Firefox, where the track can also be wider or narrower than its parent <input>. As we can see below, this makes it even more clear that the thumb’s range of motion depends solely on the slider width in this browser.

Animated gif. Firefox moves the thumb within the left and right limits of the actual range input's content-box.
Recording of the thumb motion in Firefox from one end of the slider to the other. The three cases are displayed from top to bottom. The border-box of the track perfectly fits the content-box of the slider horizontally. It’s longer and it’s shorter).

In our particular case (and, to be fair, in a lot of other cases), we can get away with not having any margin, border or padding on the track. That would mean its content-box coincides to that of the actual range input so there are no inconsistencies between browsers.

But what we need to keep in mind is that the vertical midlines of the thumbs (which we need to coincide with the fill endpoints) move between half a thumb width (or a thumb radius if we have a circular thumb) away from the start of the track and half a thumb width away from the end of the track. That’s an interval equal to the track width minus the thumb width (or the thumb diameter in the case of a circular thumb).

This can be seen in the interactive demo below where the thumb can be dragged to better see the interval its vertical midline (which we need to coincide with the fill’s endline) moves within.

See the Pen by thebabydino (@thebabydino) on CodePen.

The demo is best viewed in Chrome and Firefox.

The fill width and margin-left values are not relative to 100% (or the track width), but to the track width minus the thumb width (which is also the diameter in the particular case of a circular thumb). Also, the margin-left values don’t start from 0, but from half a thumb width (which is a thumb radius in our particular case).

$d: .5*$h; // thumb diameter
$r: .5*$d; // thumb radius
$uw: $w - $d; // useful width

.wrap {
  /* same as before */
  --dif: calc(var(--max) - var(--min));
	
  &::before {
    margin-left: calc(#{$r} + (var(--a) - var(--min))/var(--dif)*#{$uw});
    width: calc((var(--b) - var(--a))/var(--dif)*#{$uw});
  }
  
  &::after {
    margin-left: calc(#{$r} + (var(--b) - var(--min))/var(--dif)*#{$uw});
    width: calc((var(--a) - var(--b))/var(--dif)*#{$uw});
  }
}

Now the fill starts and ends exactly where it should, along the midlines of the two thumbs:

See the Pen by thebabydino (@thebabydino) on CodePen.

This one issue has been taken care of, but we still have a way bigger one. Let’s say we want to have more thumbs, say four:

Animated gif. Shows a slider with four thumbs which can pass each other and be in any order, while the fills are always between the two thumbs with the two smallest values and between the two thumbs with the two biggest values, regardless of their order in the DOM.
An example with four thumbs.

We now have four thumbs that can all pass each other and they can be in any order that we have no way of knowing. Moreover, we only have two pseudo-elements, so we cannot apply the same techniques. Can we still find a CSS-only solution?

Well, the answer is yes! But it means scrapping this solution and going for something different and way more clever — in part two of this article!

Article Series:

  1. Multi-Thumb Sliders: Particular Two-Thumb Case (This Post)
  2. Multi-Thumb Sliders: General Case (Coming Tomorrow!)

Multi-Thumb Sliders: Particular Two-Thumb Case originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
295854
The Power (and Fun) of Scope with CSS Custom Properties https://css-tricks.com/the-power-and-fun-of-scope-with-css-custom-properties/ https://css-tricks.com/the-power-and-fun-of-scope-with-css-custom-properties/#comments Wed, 27 Nov 2019 15:22:27 +0000 https://css-tricks.com/?p=298885 You’re probably already at least a little familiar with CSS variables. If not, here’s a two-second overview: they are really called custom properties, you set them in declaration blocks like --size: 1em and use them as values like font-size: var(--size);


The Power (and Fun) of Scope with CSS Custom Properties originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
You’re probably already at least a little familiar with CSS variables. If not, here’s a two-second overview: they are really called custom properties, you set them in declaration blocks like --size: 1em and use them as values like font-size: var(--size);, they differ from preprocessor variables (e.g. they cascade), and here’s a guide with way more information.

But are we using them to their full potential? Do we fall into old habits and overlook opportunities where variables could significantly reduce the amount of code we write?

This article was prompted by a recent tweet I made about using CSS variables to create dynamic animation behavior.

CSS variables are awesome, right? But scope power is often overlooked. For example, take this demo, 3 different animations but only 1 animation defined 💪 That means dynamic animations 😎 https://t.co/VN02NlC4G8 via @CodePen #CSS #animation #webdev #webdesign #coding pic.twitter.com/ig8baxr7F3

— Jhey @ NodeConfEU 2019 📍🇮🇪⌨️ (@jh3yy) November 5, 2019

Let’s look at a couple of instances where CSS variables can be used to do some pretty cool things that we may not have considered.

Basic scoping wins

The simplest and likely most common example would be scoped colors. And what’s our favorite component to use with color? The button. 😅

Consider the standard setup of primary and secondary buttons. Let’s start with some basic markup that uses a BEM syntax.

<button class="button button--primary">Primary</button>
<button class="button button--secondary">Secondary</button>

Traditionally, we might do something like this to style them up:

.button {
  padding: 1rem 1.25rem;
  color: #fff;
  font-weight: bold;
  font-size: 1.25rem;
  margin: 4px;
  transition: background 0.1s ease;
}

.button--primary {
  background: hsl(233, 100%, 50%);
  outline-color: hsl(233, 100%, 80%);
}

.button--primary:hover {
  background: hsl(233, 100%, 40%);
}

.button--primary:active {
  background: hsl(233, 100%, 30%);
}

.button--secondary {
  background: hsl(200, 100%, 50%);
  outline-color: hsl(200, 100%, 80%);
}

.button--secondary:hover {
  background: hsl(200, 100%, 40%);
}

.button--secondary:active {
  background: hsl(200, 100%, 30%);
}

That’s an awful lot of code for something not particularly complex. We haven’t added many styles and we’ve added a lot of rules to cater to the button’s different states and colors. We could significantly reduce the code with a scoped variable.

In our example, the only differing value between the two button variants is the hue. Let’s refactor that code a little then. We won’t change the markup but cleaning up the styles a little, we get this:

.button {
  padding: 1rem 1.25rem;
  color: #fff;
  font-weight: bold;
  font-size: 1.25rem;
  margin: 1rem;
  transition: background 0.1s ease;
  background: hsl(var(--hue), 100%, 50%);
  outline-color: hsl(var(--hue), 100%, 80%);

}
.button:hover {
  background: hsl(var(--hue), 100%, 40%);
}

.button:active {
  background: hsl(var(--hue), 100%, 30%);
}

.button--primary {
  --hue: 233;
}

.button--secondary {
  --hue: 200;
}

This not only reduces the code but makes maintenance so much easier. Change the core button styles in one place and it will update all the variants! 🙌

I’d likely leave it there to make it easier for devs wanting to use those buttons. But, we could take it further. We could inline the variable on the actual element and remove the class declarations completely. 😲

<button class="button" style="--hue: 233;">Primary</button>
<button class="button" style="--hue: 200;">Secondary</button>

Now we don’t need these. 👍

.button--primary {
  --hue: 233;
}

.button--secondary {
  --hue: 200;
}

Inlining those variables might not be best for your next design system or app but it does open up opportunities. Like, for example, if we had a button instance where we needed to override the color.

button.button.button--primary(style=`--hue: 20;`) Overridden

Having fun with inline variables

Another opportunity is to have a little fun with it. This is a technique I use for many of the Pens I create over on CodePen. 😉

You may be writing straightforward HTML, but in many cases, you may be using a framework, like React or a preprocessor like Pug, to write your markup. These solutions allow you to leverage JavaScript to create random inline variables. For the following examples, I’ll be using Pug. Pug is an indentation-based HTML templating engine. If you aren’t familiar with Pug, do not fear! I’ll try to keep the markup simple.

Let’s start by randomizing the hue for our buttons:

button.button(style=`--hue: ${Math.random() * 360}`) First

With Pug, we can use ES6 template literals to inline randomized CSS variables. 💪

Animation alterations

So, now that we have the opportunity to define random characteristics for an element, what else could we do? Well, one overlooked opportunity is animation. True, we can’t animate the variable itself, like this:

@keyframes grow {
  from { --scale: 1; }
  to   { --scale: 2; }
}

But we can create dynamic animations based on scoped variables. We can change the behavior of animation on the fly! 🤩

Example 1: The excited button

Let’s create a button that floats along minding its own business and then gets excited when we hover over it.

Start with the markup:

button.button(style=`--hue: ${Math.random() * 360}`) Show me attention

A simple floating animation may look like this:

@keyframes flow {
  0%, 100% {
    transform: translate(0, 0);
  }
  50% {
    transform: translate(0, -25%);
  }
}

This will give us something like this:

I’ve added a little shadow as an extra but it’s not vital. 👍

Let’s make it so that our button gets excited when we hover over it. Now, we could simply change the animation being used to something like this:

.button:hover {
  animation: shake .1s infinite ease-in-out;
}

@keyframes shake {
  0%, 100% {
    transform: translate(0, 0) rotate(0deg);
  }
  25% {
    transform: translate(-1%, 3%) rotate(-2deg);
  }
  50% {
    transform: translate(1%, 2%) rotate(2deg);
  }
  75% {
    transform: translate(1%, -2%) rotate(-1deg);
  }
}

And it works:

But, we need to introduce another keyframes definition. What if we could merge the two animations into one? They aren’t too far off from each other in terms of structure.

We could try:

@keyframes flow-and-shake {
  0%, 100% {
    transform: translate(0, 0) rotate(0deg);
  }
  25%, 75% {
    transform: translate(0, -12.5%) rotate(0deg);
  }
  50% {
    transform: translate(0, -25%) rotate(0deg);
  }
}

Although this works, we end up with an animation that isn’t quite as smooth because of the translation steps. So what else could we do? Let’s find a compromise by removing the steps at 25% and 75%.

@keyframes flow-and-shake {
  0%, 100% {
    transform: translate(0, 0) rotate(0deg);
  }
  50% {
    transform: translate(0, -25%) rotate(0deg);
  }
}

It works fine, as we expected, but here comes the trick: Let’s update our button with some variables.

.button {
  --y: -25;
  --x: 0;
  --rotation: 0;
  --speed: 2;
}

Now let’s plug them into the animation definition, along with the button’s animation properties.

.button {
  animation-name: flow-and-shake;
  animation-duration: calc(var(--speed) * 1s);
  animation-iteration-count: infinite;
  animation-timing-function: ease-in-out;
}

@keyframes flow-and-shake {
  0%, 100% {
    transform: translate(calc(var(--x) * -1%), calc(var(--y) * -1%))
      rotate(calc(var(--rotation) * -1deg));
  }
  50% {
    transform: translate(calc(var(--x) * 1%), calc(var(--y) * 1%))
      rotate(calc(var(--rotation) * 1deg));
  }
}

All is well. 👍

Let’s change those values when the button is hovered:

.button:hover {
  --speed: .1;
  --x: 1;
  --y: -1;
  --rotation: -1;
}

Nice! Now our button has two different types of animations but defined via one set of keyframes. 🤯

Let’s have a little more fun with it. If we take it a little further, we can make the button a little more playful and maybe stop animating altogether when it’s active. 😅

Example 2: Bubbles

Now that we’ve gone through some different techniques for things we can do with the power of scope, let’s put it all together. We are going to create a randomly generated bubble scene that heavily leverages scoped CSS variables.

Let’s start by creating a bubble. A static bubble.

.bubble {
  background: radial-gradient(100% 115% at 25% 25%, #fff, transparent 33%),
    radial-gradient(15% 15% at 75% 75%, #80dfff, transparent),
    radial-gradient(100% 100% at 50% 25%, transparent, #66d9ff 98%);
  border: 1px solid #b3ecff;
  border-radius: 100%;
  height: 50px;
  width: 50px;
}

We are using background with multiple values and a border to make the bubble effect — but it’s not very dynamic. We know the border-radius will always be the same. And we know the structure of the border and background will not change. But the values used within those properties and the other property values could all be random.

Let’s refactor the CSS to make use of variables:

.bubble {
  --size: 50;
  --hue: 195;
  --bubble-outline: hsl(var(--hue), 100%, 50%);
  --bubble-spot: hsl(var(--hue), 100%, 75%);
  --bubble-shade: hsl(var(--hue), 100%, 70%);
  background: radial-gradient(100% 115% at 25% 25%, #fff, transparent 33%),
    radial-gradient(15% 15% at 75% 75%, var(--bubble-spot), transparent),
    radial-gradient(100% 100% at 50% 25%, transparent, var(--bubble-shade) 98%);
  border: 1px solid var(--bubble-outline);
  border-radius: 100%;
  height: calc(var(--size) * 1px);
  width: calc(var(--size) * 1px);
}

That’s a good start. 👍

Let’s add some more bubbles and leverage the inline scope to position them as well as size them. Since we are going to start randomizing more than one value, it’s handy to have a function to generate a random number in range for our markup.

- const randomInRange = (max, min) => Math.floor(Math.random() * (max - min + 1)) + min

With Pug, we can utilize iteration to create a large set of bubbles:

- const baseHue = randomInRange(0, 360)
- const bubbleCount = 50
- let b = 0
while b < bubbleCount
  - const size = randomInRange(10, 50)
  - const x = randomInRange(0, 100)
  .bubble(style=`--x: ${x}; --size: ${size}; --hue: ${baseHue}`)
  - b++

Updating our .bubble styling allows us to make use of the new inline variables.

.bubble {
  left: calc(var(--x) * 1%);
  position: absolute;
  transform: translate(-50%, 0);
}

Giving us a random set of bubbles:

Let’s take it even further and animate those bubbles so they float from top to bottom and fade out.

.bubble {
  animation: float 5s infinite ease-in-out;
  top: 100%;
}

@keyframes float {
  from {
    opacity: 1;
    transform: translate(0, 0) scale(0);
  }
  to {
    opacity: 0;
    transform: translate(0, -100vh) scale(1);
  }
}

That’s pretty boring. They all do the same thing at the same time. So let’s randomize the speed, delay, end scale and distance each bubble is going to travel.

- const randomInRange = (max, min) => Math.floor(Math.random() * (max - min + 1)) + min
- const baseHue = randomInRange(0, 360)
- const bubbleCount = 50
- let b = 0
while b < bubbleCount
  - const size = randomInRange(10, 50)
  - const delay = randomInRange(1, 10)
  - const speed = randomInRange(2, 20)
  - const distance = randomInRange(25, 150)
  - const scale = randomInRange(100, 150) / 100
  - const x = randomInRange(0, 100)
  .bubble(style=`--x: ${x}; --size: ${size}; --hue: ${baseHue}; --distance: ${distance}; --speed: ${speed}; --delay: ${delay}; --scale: ${scale}`)
  - b++

And now, let’s update our styles

.bubble {
  animation-name: float;
  animation-duration: calc(var(--speed) * 1s);
  animation-delay: calc(var(--delay) * -1s);
  animation-iteration-count: infinite;
  animation-timing-function: ease-in-out;
}

@keyframes float {
  from {
    opacity: 1;
    transform: translate(-50%, 0) scale(0);
  }
  to {
    opacity: 0;
    transform: translate(-50%, calc(var(--distance) * -1vh)) scale(var(--scale));
  }
}

And we will get this:

With around 50 lines of code, you can create a randomly generated animated scene by honing the power of the scope! 💪

That’s it!

We can create some pretty cool things with very little code by putting CSS variables to use and leveraging some little tricks.

I do hope this article has raised some awareness for the power of CSS variable scope and I do hope you will hone the power and pass it on 😎

All the demos in this article are available in this CodePen collection.


The Power (and Fun) of Scope with CSS Custom Properties originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
https://css-tricks.com/the-power-and-fun-of-scope-with-css-custom-properties/feed/ 15 298885
Logical Operations with CSS Variables https://css-tricks.com/logical-operations-with-css-variables/ https://css-tricks.com/logical-operations-with-css-variables/#comments Wed, 11 Sep 2019 14:26:02 +0000 http://css-tricks.com/?p=292074 Very often, while using switch variables (a variable that’s either 0 or 1, a concept that’s explained in a greater detail in in this post), I wish I could perform logical operations on them. We don’t have functions …


Logical Operations with CSS Variables originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
Very often, while using switch variables (a variable that’s either 0 or 1, a concept that’s explained in a greater detail in in this post), I wish I could perform logical operations on them. We don’t have functions like not(var(--i)) or and(var(--i), var(--k)) in CSS, but we can emulate these and more with arithmetic operations in a calc() function.

This article is going to show you what calc() formulas we need to use for each logical operation and explain how and why they are used with a couple of use cases that lead to the writing of this article.

How: the formulas

not

This is a pretty straightforward one: we subtract the switch variable (let’s call it --j) from 1:

--notj: calc(1 - var(--j))

If --j is 0, then --notj is 1 (1 - 0). If j is 1, then --notj is 0 (1 - 1).

and

Now, if you’ve ever taken electronics classes (particularly something like Programmed Logic Systems or Integrated Circuits), then you already know what formula we need to use here. But let’s not jump straight into it.

The and of two operands is true if and only if both are true. The two operands in our case are two switch variables (let’s call them --k and --i). Each of them can be either 0 or 1, independently of the other. This means we can be in one out of four possible scenarios:

  • --k: 0, --i: 0
  • --k: 0, --i: 1
  • --k: 1, --i: 0
  • --k: 1, --i: 1

The result of the and operation is 1 if both our switch variables are 1 and 0 otherwise. Looking at it the other way, this result is 0 if at least one of the two switch variables is 0.

Now you need to think of it this way: the result of what arithmetic operation is 0 if at least one of the two operands is 0? That’s multiplication, as multiplying anything by 0 gives us 0!

So, our --and formula is:

--and: calc(var(--k)*var(--i))

Considering each of our four possible scenarios, we have:

  • for --k: 0, --i: 0, we have that --and is 0 (0*0)
  • for --k: 0, --i: 1, we have that --and is 0 (0*1)
  • for --k: 1, --i: 0, we have that --and is 0 (1*0)
  • for --k: 1, --i: 1, we have that --and is 1 (1*1)

nand

Since nand is not and, we need to replace the --j in the not formula with the formula for and:

--nand: calc(1 - var(--k)*var(--i))

For each of our four possible scenarios, we get:

  • for --k: 0, --i: 0, we have that --nand is 1 (1 - 0*0 = 1 - 0)
  • for --k: 0, --i: 1, we have that --nand is 1 (1 - 0*1 = 1 - 0)
  • for --k: 1, --i: 0, we have that --nand is 1 (1 - 1*0 = 1 - 0)
  • for --k: 1, --i: 1, we have that --nand is 0 (1 - 1*1 = 1 - 1)

or

The result of the or operation is 1 if at least one of our switch variables is 1 and 0 otherwise (if both of them are 0).

The first instinct here is to go for addition, but while that gives us 0 if both --k and --i are 0 and 1 if one is 0 and the other one is 1, it gives us 2 if both of them are 1. So that doesn’t really work.

But we can use the good old De Morgan’s laws, one of which states:

not (A or B) = (not A) and (not B)

This means the result of the or operation is the negation of the and operation between the negations of --k and --i. Putting this into CSS, we have:

--or: calc(1 - (1 - var(--k))*(1 - var(--i)))

For each scenario, we get:

  • for --k: 0, --i: 0, we have that --or is 0 (1 - (1 - 0)*(1 - 0) = 1 - 1*1 = 1 - 1)
  • for --k: 0, --i: 1, we have that --or is 1 (1 - (1 - 0)*(1 - 1) = 1 - 1*0 = 1 - 0)
  • for --k: 1, --i: 0, we have that --or is 1 (1 - (1 - 1)*(1 - 0) = 1 - 0*1 = 1 - 0)
  • for --k: 1, --i: 1, we have that --or is 1 (1 - (1 - 1)*(1 - 1) = 1 - 0*0 = 1 - 0)

nor

Since nor is not or, we have:

--nor: calc((1 - var(--k))*(1 - var(--i)))

For each of our four possible scenarios, we get:

  • for --k: 0, --i: 0, we have that --nor is 1 ((1 - 0)*(1 - 0) = 1*1)
  • for --k: 0, --i: 1, we have that --nor is 0 ((1 - 0)*(1 - 1) = 1*0)
  • for --k: 1, --i: 0, we have that --nor is 0 ((1 - 1)*(1 - 0) = 0*1)
  • for --k: 1, --i: 1, we have that --nor is 0 ((1 - 1)*(1 - 1) = 0*0)

xor

The result of the xor operation is 1 when one of the two operands is 1 and the other one is 0. This feels trickier at first, but, if we think this means the two operands need to be different for the result to be 1 (otherwise it’s 0), we stumble upon the right arithmetic operation to use inside calc(): subtraction!

If --k and --i are equal, then subtracting --i from --k gives us 0. Otherwise, if we have --k: 0, --i: 1, the result of the same subtraction is -1; if we have --k: 1, --i: 0, the result is 1.

Close, but not quite! We get the result we want in three out of four scenarios, but we need to get 1, not -1 in the --k: 0, --i: 1 scenario.

However, one thing that -1, 0 and 1 have in common is that multiplying them with themselves gives us their absolute value (which is 1 for both -1 and 1). So the actual solution is to multiply this difference with itself:

--xor: calc((var(--k) - var(--i))*(var(--k) - var(--i)))

Testing each of our four possible scenarios, we have:

  • for --k: 0, --i: 0, we have that --xor is 0 ((0 - 0)*(0 - 0) = 0*0)
  • for --k: 0, --i: 1, we have that --xor is 1 ((0 - 1)*(0 - 1) = -1*-1)
  • for --k: 1, --i: 0, we have that --xor is 1 ((1 - 0)*(1 - 0) = 1*1)
  • for --k: 1, --i: 1, we have that --xor is 0 ((1 - 1)*(1 - 1) = 0*0)

Why: Use cases

Let’s see a couple of examples that make use of logical operations in CSS. Note that I won’t detail other aspects of these demos as they’re outside the scope of this particular article.

Hide disabled panel only on small screens

This is a use case I came across while working on an interactive demo that lets users control various parameters to change a visual result. For more knowledgeable users, there’s also a panel of advanced controls that’s disabled by default. It can, however, be enabled in order to get access to manually controlling even more parameters.

Since this demo is supposed to be responsive, the layout changes with the viewport. We also don’t want things to get crammed on smaller screens if we can avoid it, so there’s no point in showing the advanced controls if they’re disabled and we’re in the narrow screen case.

The screenshot collage below shows the results we get for each the four possible scenarios.

Screenshot collage.
Collage of the possible cases.

So let’s see what this means in terms of CSS!

First off, on the <body>, we use a switch that goes from 0 in the narrow screen case to 1 in the wide screen case. We also change the flex-direction this way (if you want a more detailed explanation of how this works, check out my second article on DRY switching with CSS variables).

body {
  --k: var(--wide, 0);
  display: flex;
  flex-direction: var(--wide, column);
	
  @media (orientation: landscape) { --wide: 1 }
}

We then have a second switch on the advanced controls panel. This second switch is 0 if the checkbox is unchecked and 1 if the checkbox is :checked. With the help of this switch, we give our advanced controls panel a disabled look (via a filter chain) and we also disable it (via pointer-events). Here, not comes in handy, as we want to decrease the contrast and the opacity in the disabled case:

.advanced {
  --i: var(--enabled, 0);
  --noti: calc(1 - var(--i));
  filter: 
    contrast(calc(1 - var(--noti)*.9)) 
    opacity(calc(1 - var(--noti)*.7));
  pointer-events: var(--enabled, none);
	
  [id='toggle']:checked ~ & { --enabled: 1 }
}

We want the advanced controls panel to stay expanded if we’re in the wide screen case (so if --k is 1), regardless of whether the checkbox is :checked or not, or if the checkbox is :checked (so if --i is 1), regardless of whether we’re in the wide screen case or not.

This is precisely the or operation!

So we compute an --or variable:

.advanced {
  /* same as before */
  --or: calc(1 - (1 - var(--k))*(1 - var(--i)));
}

If this --or variable is 0, this means we’re in the narrow screen case and our checkbox is unchecked, so we want to zero the height of the advanced controls panel and also its vertical margin:

.advanced {
  /* same as before */
  margin: calc(var(--or)*#{$mv}) 0;
  height: calc(var(--or)*#{$h});
}

This gives us the desired result (live demo).

Use the same formulas to position multiple faces of a 3D shape

This is a use case I came across while working on the personal project of CSS-ing the Johnson solids this summer.

Let’s take a look at one of these shapes, for example, the gyroelongated pentagonal rotunda (J25), in order to see how logical operations are useful here.

The shape we want to get.

This shape is made up out of a pentagonal rotunda without the big decagonal base and a decagonal antiprism without its top decagon. The interactive demo below shows how these two components can be built by folding their nets of faces into 3D and then joined to give us the shape we want.

See the Pen by thebabydino (@thebabydino) on CodePen.

As it can be seen above, the faces are either a part of the antiprism or a part of the rotunda. This is where we introduce our first switch variable --i. This is 0 for the faces that are a part of the antiprism and 1 for the faces that are a part of the rotunda. The antiprism faces have a class of .mid because we can add another rotunda to the other antiprism base and then the antiprism would be in the middle. The rotunda faces have a class of .cup because this part does look like a coffee cup… without a handle!

The rotunda looks like an upside down up cup without a handle.
.mid { --i: 0 }
.cup { --i: 1 }

Focusing only on the lateral faces, these can have a vertex pointing up or down. This is where we introduce our second variable --k. This is 0 if they have a vertex pointing up (such faces have a .dir class) and 1 if they’re reversed and have a vertex pointing down (these faces have a class of .rev)

.dir { --k: 0 }
.rev { --k: 1 }

The antiprism has 10 lateral faces (all triangles) pointing up, each attached to an edge of its decagonal base that’s also a base for the compound shape. It also has 10 lateral faces (all triangles as well) pointing down, each attached to an edge of its other decagonal base (the one that’s also the decagonal base of the rotunda and is therefore not a base for the compound shape).

The rotunda has 10 lateral faces pointing up, alternating triangles and pentagons, each attached to the decagonal base that’s also a base for the antiprism (so it’s not a base for the compound shape as well). It also has 5 lateral faces, all triangles, pointing down, each attached to an edge of its pentagonal base.

The interactive demo below allows us to better see each of these four groups of faces by highlighting only one at a time. You can use the arrows at the bottom to pick which group of faces gets highlighted. You can also enable the rotation around the y axis and change the shape’s tilt.

See the Pen by thebabydino (@thebabydino) on CodePen.

As previously mentioned, the lateral faces can be either triangles or pentagons:

.s3gon { --p: 0 }
.s5gon { --p: 1 }

Since all of their lateral faces (.lat) of both the antiprism and the rotunda have one edge in common with one of the two base faces of each shape, we call these common edges the base edges of the lateral faces.

The interactive demo below highlights these edges, their end points and their mid points and allows viewing the shapes from various angles thanks to the auto-rotations around the y axis which can be started/ paused at any moment and to the manual rotations around the x axis which can be controlled via the sliders.

See the Pen by thebabydino (@thebabydino) on CodePen.

In order to make things easier for ourselves, we set the transform-origin of the .lat faces on the middle of their base edges (bottom horizontal edges).

SVG illustration.
Highlighting the base edges and their midpoints (live).

We also make sure we position these faces such as to have these midpoints dead in the middle of the scene element containing our entire 3D shape.

Having the transform-origin coincide with the midpoint the base edge means that any rotation we perform on a face is going to happen around the midpoint of its base edge, as illustrated by the interactive demo below:

See the Pen by thebabydino (@thebabydino) on CodePen.

We place our lateral faces where we want them to be in four steps:

  1. We rotate them around their y axis such that their base edges are now parallel to their final positions. (This also rotates their local system of coordinates — the z axis of an element always points in the direction that element faces.)
  2. We translate them such that their base edges coincide with their final positions (along the edges of the base faces of the two components).
  3. If they need to have a vertex pointing down, we rotate them around their z axis by half a turn.
  4. We rotate them around their x axis into their final positions

These steps are illustrated by the interactive demo below, where you can go through them and also rotate the entire shape (using the play/pause button for the y axis rotation and the slider for the x axis rotation).

See the Pen by thebabydino (@thebabydino) on CodePen.

The y axis rotation value is based mostly on the face indices and less on our switch variables, though it depends on these as well.

The structure is as follows:

- var n = 5; //- number of edges/ vertices of small base

section.scene
  //- 3D shape element
  .s3d
    //- the faces, each a 2D shape element (.s2d)
    
    //- lateral (.lat) antiprism (.mid) faces, 
    //- first half pointing up (.dir), others pointing down (.rev)
    //- all of them being triangles (.s3gon)
    - for(var j = 0; j < 4*n; j++)
      .s2d.mid.lat.s3gon(class=j < 2*n ? 'dir' : 'rev')
    
    //- lateral (.lat) rotunda (.cup) faces that point up (.dir), 
    //- both triangles (.s3gon) and pentagons (.s5gon)
    - for(var j = 0; j < n; j++)
      .s2d.cup.lat.s3gon.dir
      .s2d.cup.lat.s5gon.dir
    //- lateral (.lat) rotunda (.cup) faces that point down (.rev)
    //- all of them triangles (.s3gon)
    - for(var j = 0; j < n; j++)
      .s2d.cup.lat.s3gon.rev

    //- base faces, 
    //- one for the antiprism (.mid), 
    //- the other for the rotunda (.cup)
    .s2d.mid.base(class=`s${2*n}gon`)
    .s2d.cup.base(class=`s${n}gon`)

Which gives us the following HTML:

<section class="scene">
  <div class="s3d">
    <!-- LATERAL faces -->
    <div class="s2d mid lat s3gon dir"></div>
    <!-- 9 more identical faces, 
         so we have 10 lateral antiprism faces pointing up -->

    <div class="s2d mid lat s3gon rev"></div>
    <!-- 9 more identical faces, 
         so we have 10 lateral antiprism faces pointing down -->

    <div class="s2d cup lat s3gon dir"></div>
    <div class="s2d cup lat s5gon dir"></div>
    <!-- 4 more identical pairs, 
         so we have 10 lateral rotunda faces pointing up -->

    <div class="s2d cup lat s3gon rev"></div>
    <!-- 4 more identical faces, 
         so we have 5 lateral rotunda faces pointing down -->

    <!-- BASE faces -->
    <div class="s2d mid base s10gon"></div>
    <div class="s2d cup base s5gon"></div>
  </div>
</section>

This means faces 0... 9 are the 10 lateral antiprism faces pointing up, faces 10... 19 are the 10 lateral antiprism faces pointing down, faces 20... 29 are the 10 lateral rotunda faces pointing up and faces 30... 34 are the 5 lateral rotunda faces pointing down.

So what we do here is set an index --idx on the lateral faces.

$n: 5; // number of edges/ vertices of small base

.lat {
  @for $i from 0 to 2*$n {
    &:nth-child(#{2*$n}n + #{$i + 1}) { --idx: #{$i} }
  }
}

This index starts at 0 for each group of faces, which means the indices for faces 0... 9, 10... 19 and 20... 29 go from 0 through 9, while the indices for faces 30... 34 go from 0 through 4. Great, but if we just multiply these indices with the base angle1 of the common decagon to get the y axis rotation we want at this step:

--ay: calc(var(--idx)*#{$ba10gon});

transform: rotatey(var(--ay))

…then we get the following final result. I’m showing the final result here because it’s a bit difficult to see what’s wrong by looking at the intermediate result we get after only applying the rotation around the y axis.

See the Pen by thebabydino (@thebabydino) on CodePen.

This is… not quite what we were going for!

So let’s see what problems the above result has and how to solve them with the help of our switch variables and boolean operations on them.

The first issue is that the lateral antiprism faces pointing up need to be offset by half of a regular decagon’s base angle. This means adding or subtracting .5 from --idx before multiplying with the base angle, but only for these faces.

See the Pen by thebabydino (@thebabydino) on CodePen.

The faces we want to target are the faces for which both of --i and --k are 0, so what we need here is multiply the result of their nor with .5:

--nor: calc((1 - var(--k))*(1 - var(--i)));
--j: calc(var(--idx) + var(--nor)*.5);
--ay: calc(var(--j)*#{$ba10gon});

transform: rotatey(var(--ay));

The second issue is that the lateral rotunda faces pointing down are not distributed as they should be, such that each of them has a base edge in common with the base pentagon and the vertex opposing the base in common with the triangular rotunda faces pointing up. This means multiplying --idx by 2, but only for these faces.

See the Pen by thebabydino (@thebabydino) on CodePen.

What we’re targeting now are the faces for which both --i and --k are 1 (so the faces for which the result of the and operation is 1), so what we need is to multiply --idx with 1 plus their and:

--and: calc(var(--k)*var(--i));
--nor: calc((1 - var(--k))*(1 - var(--i)));
--j: calc((1 + var(--and))*var(--idx) + var(--nor)*.5);
--ay: calc(var(--j)*#{$ba10gon});

transform: rotatey(var(--ay));

The next step is the translation for which we use translate3d(). We don’t move any of our faces left or right, so the value along the x axis is always 0. We do move them however vertically (along the y axis) and forward (along the z axis)

Vertically, we want the cup faces that will later get rotated to point down to have their base edge in the plane of the small (pentagonal) base of the cup (and of the compound shape). This means the faces for which --i is 1 and --k is 1 get moved up (negative direction) by half the total height of the compound shape (a total height which we have computed to be $h). So we need the and operation here.

// same as before
--and: calc(var(--i)*var(--k));
--y: calc(var(--and)*#{-.5*$h});

transform: rotatey(var(--ay)) 
           translate3d(0, var(--y, 0), var(--z, 0));

We also want all the other cup faces as well as the antiprism faces that will eventually point down to have their base edge in the common plane between the cup and the antiprism. This means the faces for which --i is 1 and --k is 0 as well as the faces for which --i is 0 and --k is 1 get translated down (positive direction) by half the height of the compound shape and then back up (negative direction) by the height of the antiprism ($h-mid). And what do you know, this is the xor operation!

// same as before
--xor: calc((var(--k) - var(--i))*(var(--k) - var(--i)));
--and: calc(var(--i)*var(--k));
--y: calc(var(--xor)*#{.5*$h - $h-mid} - 
          var(--and)*#{.5*$h});

transform: rotatey(var(--ay)) 
           translate3d(0, var(--y, 0), var(--z, 0));

Finally, we want the antiprism faces that will remain pointing up to be in the bottom base plane of the compound shape (and of the antiprism). This means the faces for which --i is 0 and --k is 0 get translated down (positive direction) by half the total height of the compound shape. So what we use here is the nor operation!

// same as before
--nor: calc((1 - var(--k))*(1 - var(--i)));
--xor: calc((var(--k) - var(--i))*(var(--k) - var(--i)));
--and: calc(var(--i)*var(--k));

--y: calc(var(--nor)*#{.5*$h} + 
          var(--xor)*#{.5*$h - $h-mid} - 
          var(--and)*#{.5*$h});

transform: rotatey(var(--ay)) 
           translate3d(0, var(--y, 0), var(--z, 0));

See the Pen by thebabydino (@thebabydino) on CodePen.

Along the z direction, we want to move the faces such that their base edges coincide with the edges of the base faces of the compound shape or the edges of the common base (which is not a face of the compound shape) shared by the two 3D components. For the top faces of the cup (which we later rotate to point down), the placement is on the edges of a pentagon, while for all the other faces of the compound shape, the placement is on the edges of a decagon.

This means the faces for which --i is 1 and --k is 1 get translated forward by the inradius of the pentagonal base while all the other faces get translated forward by the inradius of a decagonal base. So the operations we need here are and and nand!

// same as before
--and: calc(var(--i)*var(--k));
--nand: calc(1 - var(--and));
--z: calc(var(--and)*#{$ri5gon} + var(--nand)*#{$ri10gon});

transform: rotatey(var(--ay)) 
           translate3d(0, var(--y, 0), var(--z, 0));

See the Pen by thebabydino (@thebabydino) on CodePen.

Next, we want to make all .rev (for which --k is 1) faces point down. This is pretty straightforward and doesn’t require any logical operation, we just need to add a half a turn rotation around the z axis to the transform chain, but only for the faces for which --k is 1:

// same as before
--az: calc(var(--k)*.5turn);

transform: rotatey(var(--ay)) 
           translate3d(0, var(--y), var(--z))
           rotate(var(--az));

See the Pen by thebabydino (@thebabydino) on CodePen.

The pentagonal faces (for which --p is 1) are then all rotated around the x axis by a certain angle:

--ax: calc(var(--p)*#{$ax5});

In the case of the triangular faces (for which --p is 0, meaning we need to use --notp), we have a certain rotation angle for the faces of the antiprism ($ax3-mid), another angle for the faces of the rotunda that point up ($ax3-cup-dir) and yet another angle for the rotunda faces pointing down ($ax3-cup-red).

The antiprism faces are those for which --i is 0, so we need to multiply their corresponding angle value with --noti here. The rotunda faces are those for which --i is 1, and out of these, the ones pointing up are those for which --k is 0 and the ones pointing down are those for which --k is 1.

--notk: calc(1 - var(--k));
--noti: calc(1 - var(--i));
--notp: calc(1 - var(--p));

--ax: calc(var(--notp)*(var(--noti)*#{$ax3-mid} + 
                        var(--i)*(var(--notk)*#{$ax3-cup-dir} + var(--k)*#{$ax3-cup-rev})) +
           var(--p)*#{$ax5});

transform: rotatey(var(--ay)) 
           translate3d(0, var(--y), var(--z))
           rotate(var(--az)) 
           rotatex(var(--ax));

This gives us the final result!

See the Pen by thebabydino (@thebabydino) on CodePen.


1 For any regular polygon (such as any of the faces of our shapes), the arc corresponding to one edge, as well as the angle between the circumradii to this edge’s ends (our base angle) is a full circle (360°) over the number of edges. In the case of an equilateral triangle, the angle is 360°/3 = 120°. For a regular pentagon, the angle is 360°/5 = 72°. For a regular decagon, the angle is 360°/10 = 36°. ↪️

See the Pen by thebabydino (@thebabydino) on CodePen.


Logical Operations with CSS Variables originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
https://css-tricks.com/logical-operations-with-css-variables/feed/ 4 292074
Breaking CSS Custom Properties out of :root Might Be a Good Idea https://css-tricks.com/breaking-css-custom-properties-out-of-root-might-be-a-good-idea/ https://css-tricks.com/breaking-css-custom-properties-out-of-root-might-be-a-good-idea/#comments Wed, 27 Mar 2019 14:54:05 +0000 http://css-tricks.com/?p=284609 CSS Custom Properties have been a hot topic for a while now, with tons of great articles about them, from great primers on how they work to creative tutorials to do some real magic with them. If you’ve read …


Breaking CSS Custom Properties out of :root Might Be a Good Idea originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
CSS Custom Properties have been a hot topic for a while now, with tons of great articles about them, from great primers on how they work to creative tutorials to do some real magic with them. If you’ve read more than one or two articles on the topic, then I’m sure you’ve noticed that they start by setting up the custom properties on the :root about 99% of the time.

While putting custom properties on the :root is great for things that you need to be available throughout your site, there are times when it makes more sense to scope your custom properties locally.

In this article, we’ll be exploring:

  • Why we put custom properties on the :root to begin with.
  • Why global scoping isn’t right for everything.
  • How to overcome class clashing with locally scoped custom properties

What’s the deal with custom properties and :root?

Before we jump into looking at the global scope, I think it’s worth looking at why everyone sets custom properties in the :root to begin with.

I’ve been declaring custom properties on the :root without even a second thought. Pretty much everyone does it without even a mention of why — including the official specification.

When the subject of :root is actually breached, it mentions how :root is the same as html, but with higher specificity, and that’s about it.

But does that higher specificity really matter?

Not really. All it does is select html with a higher specificity, the same way a class selector has higher specificity than an element selector when selecting a div.

:root {
  --color: red;
}

html {
  --color: blue;
}

.example {
  background: var(--color);
  /* Will be red because of :root's higher specificity */
}

The main reason that :root is suggested is because CSS isn’t only used to style HTML documents. It is also used for XML and SVG files.

In the case of XML and SVG files, :root isn’t selecting the html element, but rather their root (such as the svg tag in an SVG file).

Because of this, the best practice for a globally-scoped custom property is the :root. But if you’re making a website, you can throw it on an html selector and not notice a difference.

That said, with everyone using :root, it has quickly become a “standard.” It also helps separate variables to be used later on from selectors which are actively styling the document.

Why global scope isn’t right for everything

With CSS pre-processors, like Sass and Less, most of us keep variables tucked away in a partial dedicated to them. That works great, so why should we consider locally scoping variables all of a sudden?

One reason is that some people might find themselves doing something like this.

:root {
  --clr-light: #ededed;
  --clr-dark: #333;
  --clr-accent: #EFF;
  --ff-heading: 'Roboto', sans-serif;
  --ff-body: 'Merriweather', serif;
  --fw-heading: 700;
  --fw-body: 300;
  --fs-h1: 5rem;
  --fs-h2: 3.25rem;
  --fs-h3: 2.75rem;
  --fs-h4: 1.75rem;
  --fs-body: 1.125rem;
  --line-height: 1.55;
  --font-color: var(--clr-light);
  --navbar-bg-color: var(--clr-dark);
  --navbar-logo-color: var(--clr-accent);
  --navbar-border: thin var(--clr-accent) solid;
  --navbar-font-size: .8rem;
  --header-color: var(--clr-accent);
  --header-shadow: 2px 3px 4px rgba(200,200,0,.25);
  --pullquote-border: 5px solid var(--clr-light);
  --link-fg: var(--clr-dark);
  --link-bg: var(--clr-light);
  --link-fg-hover: var(--clr-dark);
  --link-bg-hover: var(--clr-accent);
  --transition: 250ms ease-out;
  --shadow: 2px 5px 20px rgba(0, 0, 0, .2);
  --gradient: linear-gradient(60deg, red, green, blue, yellow);
  --button-small: .75rem;
  --button-default: 1rem;
  --button-large: 1.5rem;
}

Sure, this gives us one place where we can manage styling with custom properties. But, why do we need to define my --header-color or --header-shadow in my :root? These aren’t global properties, I’m clearly using them in my header and no where else.

If it’s not a global property, why define it globally? That’s where local scoping comes into play.

Locally scoped properties in action

Let’s say we have a list to style, but our site is using an icon system — let’s say Font Awesome for simplicity’s sake. We don’t want to use the disc for our ul bullets — we want a custom icon!

If I want to switch out the bullets of an unordered list for Font Awesome icons, we can do something like this:

ul {
  list-style: none;
}

li::before {
  content: "\f14a"; /* checkbox */
  font-family: "Font Awesome Free 5";
  font-weight: 900;
  float: left;
  margin-left: -1.5em;
}

While that’s super easy to do, one of the problems is that the icon becomes abstract. Unless we use Font Awesome a lot, we aren’t going to know that f14a means, let alone be able to identify it as a checkbox icon. It’s semantically meaningless.

We can help clarify things with a custom property here.

ul {
  --checkbox-icon: "\f14a";
  list-style: none;
}

This becomes a lot more practical once we start having a few different icons in play. Let’s up the complexity and say we have three different lists:

<ul class="icon-list checkbox-list"> ... </ul>

<ul class="icon-list star-list"> ... </ul>

<ul class="icon-list bolt-list"> ... </ul>

Then, in our CSS, we can create the custom properties for our different icons:

.icon-list {
  --checkbox: "\f14a";
  --star: "\f005";
  --bolt: "\f0e7";

  list-style: none;
}

The real power of having locally scoped custom properties comes when we want to actually apply the icons.

We can set content: var(--icon) on our list items:

.icon-list li::before {
  content: var(--icon);
  font-family: "Font Awesome Free 5";
  font-weight: 900;
  float: left;
  margin-left: -1.5em;
}

Then we can define that icon for each one of our lists with more meaningful naming:

.checkbox-list {
  --icon: var(--checkbox);
}

.star-list {
  --icon: var(--star);
}

.bolt-list {
  --icon: var(--bolt);
}

We can step this up a notch by adding colors to the mix:

.icon-list li::before {
  content: var(--icon);
  color: var(--icon-color);
  /* Other styles */
}

Moving icons to the global scope

If we’re working with an icon system, like Font Awesome, then I’m going to assume that we’d be using them for more than just replacing the bullets in unordered lists. As long as we’re using them in more than one place it makes sense to move the icons to the :root as we want them to be available globally.

Having icons in the :root doesn’t mean we can’t still take advantage of locally scoped custom properties, though!

:root {
  --checkbox: "\f14a";
  --star: "\f005";
  --bolt: "\f0e7";
  
  --clr-success: rgb(64, 209, 91);
  --clr-error: rgb(219, 138, 52);
  --clr-warning: rgb(206, 41, 26);
}

.icon-list li::before {
  content: var(--icon);
  color: var(--icon-color);
  /* Other styles */
}

.checkbox-list {
  --icon: var(--checkbox);
  --icon-color: var(--clr-success);
}

.star-list {
  --icon: var(--star);
  --icon-color: var(--clr-warning);
}

.bolt-list {
  --icon: var(--bolt);
  --icon-color: var(--clr-error);
}

Adding fallbacks

We could either put in a default icon by setting it as the fallback (e.g. var(--icon, "/f1cb")), or, since we’re using the content property, we could even put in an error message var(--icon, "no icon set").

See the Pen
Custom list icons with CSS Custom Properties
by Kevin (@kevinpowell)
on CodePen.

By locally scoping the --icon and the --icon-color variables, we’ve greatly increased the readability of our code. If someone new were to come into the project, it will be a whole lot easier for them to know how it works.

This isn’t limited to Font Awesome, of course. Locally scoping custom properties also works great for an SVG icon system:

:root {
  --checkbox: url(../assets/img/checkbox.svg);
  --star: url(../assets/img/star.svg);
  --baby: url(../assets/img/baby.svg);
}

.icon-list {
  list-style-image: var(--icon);
}

.checkbox-list { --icon: checkbox; }
.star-list { --icon: star; }
.baby-list { --icon: baby; }

Using locally scoped properties for more modular code

While the example we just looked at works well to increase the readability of our code — which is awesome — we can do a lot more with locally scoped properties.

Some people love CSS as it is; others hate working with the global scope of the cascade. I’m not here to discuss CSS-in-JS (there are enough really smart people already talking about that), but locally scoped custom properties offer us a fantastic middle ground.

By taking advantage of locally scoped custom properties, we can create very modular code that takes a lot of the pain out of trying to come up with meaningful class names.

Let’s um, scope the scenario.

Part of the reason people get frustrated with CSS is that the following markup can cause problems when we want to style something.

<div class="card">
  <h2 class="title">This is a card</h2>
  <p>Lorem ipsum dolor sit, amet consectetur adipisicing elit. Libero, totam.</p>
  <button class="button">More info</button>
</div>

<div class="cta">
  <h2 class="title">This is a call to action</h2>
  <p>Lorem, ipsum dolor sit amet consectetur adipisicing elit. Aliquid eveniet fugiat ratione repellendus ex optio, ipsum modi praesentium, saepe, quibusdam rem quaerat! Accusamus, saepe beatae!</p>
  <button class="button">Buy now</button>
</div>

If I create a style for the .title class, it will style both the elements containing the .card and .cta classes at the same time. We can use a compound selector (i.e. .card .title), but that raises the specificity which can lead to less maintainability. Or, we can take a BEM approach and rename our .title class to .card__title and .cta__title to isolate those elements a little more.

Locally scoped custom properties offer us a great solution though. We can apply them to the elements where they’ll be used:

.title {
  color: var(--title-clr);
  font-size: var(--title-fs);
}

.button {
  background: var(--button-bg);
  border: var(--button-border);
  color: var(--button-text);
}

Then, we can control everything we need within their parent selectors, respectively:

.card {
  --title-clr: #345;
  --title-fs: 1.25rem;
  --button-border: 0;
  --button-bg: #333;
  --button-text: white;
}

.cta {
  --title-clr: #f30;
  --title-fs: 2.5rem;
  --button-border: 0;
  --button-bg: #333;
  --button-text: white;
}

Chances are, there are some defaults, or commonalities, between buttons or titles even when they are in different components. For that, we could build in fallbacks, or simply style those as we usually would.

.button {
  /* Custom variables with default values */
  border: var(--button-border, 0);    /* Default: 0 */
  background: var(--button-bg, #333); /* Default: #333 */
  color: var(--button-text, white);   /* Default: white */

  /* Common styles every button will have */
  padding: .5em 1.25em;
  text-transform: uppercase;
  letter-spacing: 1px;
}

We could even use calc() to add a scale to our button, which would have the potential to remove the need for .btn-sm, btn-lg type classes (or it could be built into those classes, depending on the situation).

.button {
  font-size: calc(var(--button-scale) * 1rem);
  /* Multiply `--button-scale` by `1rem` to add unit */
}

.cta {
  --button-scale: 1.5;
}

Here is a more in-depth look at all of this in action:

See the Pen
Custom list icons with CSS Custom Properties
by Kevin (@kevinpowell)
on CodePen.

Notice in that example above that I have used some generic classes, such as .title and .button, which are styled with locally scoped properties (with the help of fallbacks). With those being setup with custom properties, I can define those locally within the parent selector, effectively giving each its own style without the need of an additional selector.

I also set up some pricing cards with modifier classes on them. Using the generic .pricing class, I set everything up, and then using modifier classes, I redefined some of the properties, such as --text, and --background, without having to worry about using compound selectors or additional classes.

By working this way, it makes for very maintainable code. It’s easy to go in and change the color of a property if we need to, or even come in and create a completely new theme or style, like the rainbow variation of the pricing card in the example.

It takes a bit of foresight when initially setting everything up, but the payoff can be awesome. It might even seem counter-intuitive to how you are used to approaching styles, but next time you go to create a custom property, try keeping it defined locally if it doesn’t need to live globally, and you’ll start to see how useful it can be.


Breaking CSS Custom Properties out of :root Might Be a Good Idea originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
https://css-tricks.com/breaking-css-custom-properties-out-of-root-might-be-a-good-idea/feed/ 9 284609
Responsive Designs and CSS Custom Properties: Building a Flexible Grid System https://css-tricks.com/responsive-designs-and-css-custom-properties-building-a-flexible-grid-system/ https://css-tricks.com/responsive-designs-and-css-custom-properties-building-a-flexible-grid-system/#comments Tue, 26 Feb 2019 15:28:44 +0000 http://css-tricks.com/?p=282899 Last time, we looked at a few possible approaches for declaring and using CSS custom properties in responsive designs. In this article, we’ll take a closer look at CSS variables and how to use them in reusable components and …


Responsive Designs and CSS Custom Properties: Building a Flexible Grid System originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
Last time, we looked at a few possible approaches for declaring and using CSS custom properties in responsive designs. In this article, we’ll take a closer look at CSS variables and how to use them in reusable components and modules. We will learn how to make our variables optional and set fallback values.

As an example, we will build a simple grid system based on flexbox. Grid systems play a vital role in responsive designs. However, building a grid system that is flexible and lightweight at the same time can be a tricky task. Let’s see what the common approaches towards grid systems are and how CSS custom properties can help us build them.

Article Series:

  1. Defining Variables and Breakpoints
  2. Building a Flexible Grid System (This Post)

A simple CSS grid system

Let’s start with a 12-column grid system:

.container {
	max-width: 960px;
	margin: 0 auto;
	display: flex;
}

.col-1 { flex-basis: 8.333%; }
.col-2 { flex-basis: 16.666%; }
.col-3 { flex-basis: 25%; }
.col-4 { flex-basis: 33.333%; }
.col-5 { flex-basis: 41.666%; }
.col-6 { flex-basis: 50%; }
/* and so on up to 12... */

See the Pen
#5 Building responsive features with CSS custom properties
by Mikołaj (@mikolajdobrucki)
on CodePen.

There’s quite a lot of repetition and hard-coded values here. Not to mention how many more will be generated once we add more breakpoints, offset classes, etc.

Building a grid system with Sass

To make our grid example more readable and maintainable, let’s use Sass to preprocess our CSS:

$columns: 12; // Number of columns in the grid system

.container {
	display: flex;
	flex-wrap: wrap;
	margin: 0 auto;
	max-width: 960px;
}

@for $width from 1 through $columns {
	.col-#{$width} {
		flex-basis: $width / $columns * 100%;
	}  
}

See the Pen
#6 Building responsive features with CSS custom properties
by Mikołaj (@mikolajdobrucki)
on CodePen.

This is definitely much easier to work with. As we develop our grid further and, let’s say, would like to change it from 12 columns to 16 columns, all we have to do is to update a single variable (in comparison to dozens of classes and values). But… as long as our Sass is shorter and more maintainable now, the compiled code is identical to the first example. We are still going to end up with a massive amount of code in the final CSS file. Let’s explore what happens if we try to replace the Sass variables with CSS custom properties instead.

Building a grid system with CSS custom properties

Before we start playing with CSS custom properties, let’s start with some HTML first. Here’s the layout we’re aiming for:

It consists of three elements: a header, a content section and a sidebar. Let’s create markup for this view, giving each of the elements a unique semantic class (header, content, sidebar) and a column class which indicates that this element is a part of a grid system:

<div class="container">
	<header class="header column">
		header
	</header>
	<main class="content column">
		content
	</main>
	<aside class="sidebar column">
		sidebar
	</aside>
</div>

Our grid system, as before, is based on a 12-column layout. You can envision it as an overlay covering our content areas:

So .header takes all 12 columns, .content takes eight columns (66.(6)% of the total width) and .sidebar takes four columns (33.(3)% of the total width). In our CSS, we would like to be able to control the width of each section by changing a single custom property:

.header {
	--width: 12;
}

.content {
	--width: 8;
}

.sidebar {
	--width: 4;
}

To make it work, all we need to do is write a rule for the .column class. Lucky for us, most of the work is already done! We can re-use the Sass from the previous chapter and replace the Sass variables with CSS custom properties:

.container {
	display: flex;
	flex-wrap: wrap;
	margin: 0 auto;
	max-width: 960px;
}

.column {
	--columns: 12; /* Number of columns in the grid system */
	--width: 0; /* Default width of the element */

	flex-basis: calc(var(--width) / var(--columns) * 100%);
}

Notice two important changes here:

  1. The --columns variable is now declared inside of the .column rule. The reason is that this variable is not supposed to be used outside of the scope of this class.
  2. The math equation we perform in the flex-basis property is now enclosed within a calc() function. Math calculations that are written in Sass are compiled by the preprocessor and don’t need additional syntax. calc(), on the other hand, lets us perform math calculations in live CSS. The equation always needs to be wrapped within a calc() function.

On a very basic level, that’s it! We’ve just built a 12-column grid system with CSS custom properties. Congratulations! We could call it a day and happily finish this article right now, but… we usually need a grid system that is a bit more sophisticated. And this is when things are getting really interesting.

See the Pen
#8 Building responsive features with CSS custom properties
by Mikołaj (@mikolajdobrucki)
on CodePen.

Adding a breakpoint to the grid

Most times, we need layouts to look different on various screen sizes. Let’s say that in our case we want the layout to remain as it is on a large viewport (e.g. desktop) but have all three elements become full-width on smaller screens (e.g. mobile).

So, in this case, we would like our variables to look as follows:

.header {
	--width-mobile: 12;
}

.content {
	--width-mobile: 12;
	--width-tablet: 8; /* Tablet and larger */
}

.sidebar {
	--width-mobile: 12;
	--width-tablet: 4; /* Tablet and larger */
}

.content and .sidebar each hold two variables now. The first variable (--width-mobile) is a number of columns an element should take by default, and the second one (--width-tablet) is the number of columns an element should take on larger screens. The .header element doesn’t change; it always takes the full width. On larger screens, the header should simply inherit the width it has on mobile.

Now, let’s update our .column class.

CSS variables and fallback

To make the mobile version work as expected, we need to alter the .column class as follows:

.column {
	--columns: 12; /* Number of columns in the grid system */
	--width: var(--width-mobile, 0); /* Default width of the element */
	
	flex-basis: calc(var(--width) / var(--columns) * 100%);
}

Basically, we replace the value of the --width variable with --width-mobile. Notice that the var() function takes two arguments now. The first of them is a default value. It says: “If a --width-mobile variable exists in a given scope, assign its value to the --width variable.” The second argument is a fallback. In other words: “If a --width-mobile variable is not declared in a given scope, assign this fallback value to the --width variable.” We set this fallback to prepare for a scenario where some grid elements won’t have a specified width.

For example, our .header element has a declared --width-mobile variable which means the --width variable will be equal to it and the flex-basis property of this element will compute to 100%:

.header {
	--width-mobile: 12;
}

.column {
	--columns: 12;
	--width: var(--width-mobile, 0); /* 12, takes the value of --width-mobile */
	
	flex-basis: calc(var(--width) / var(--columns) * 100%); /* 12 ÷ 12 × 100% = 100% */
}

If we remove the --width-mobile variable from the .header rule, then the --width variable will use a fallback value:

.header {
	/* Nothing here... */
}

.column {
	--columns: 12;
	--width: var(--width-mobile, 0); /* 0, takes the the fallback value */
	
	flex-basis: calc(var(--width) / var(--columns) * 100%); /* 0 ÷ 12 × 100% = 0% */
}

Now, as we understand how to set fallback for CSS custom properties, we can create a breakpoint, by adding a media query to our code:

.column {
	--columns: 12; /* Number of columns in the grid system */
	--width: var(--width-mobile, 0); /* Default width of the element */
	
	flex-basis: calc(var(--width) / var(--columns) * 100%);
}

@media (min-width: 576px) {
	.column {
		--width: var(--width-tablet); /* Width of the element on tablet and up */
	}
}

This works exactly as expected, but only for the content and sidebar, i.e. for the elements that have specified both --width-mobile and --width-tablet. Why?

The media query we created applies to all .column elements, even those that don’t have a --width-tablet variable declared in their scope. What happens if we use a variable that is not declared? The reference to the undeclared variable in a var() function is then considered invalid at computed-value time, i.e. invalid at the time a user agent is trying to compute it in the context of a given declaration.

Ideally, in such a case, we would like the --width: var(--width-tablet); declaration to be ignored and the previous declaration of --width: var(--width-mobile, 0); to be used instead. But this is not how custom properties work! In fact, the invalid --width-tablet variable will still be used in the flex-basis declaration. A property that contains an invalid var() function always computes to its initial value. So, as flex-basis: calc(var(--width) / var(--columns) * 100%); contains an invalid var() function the whole property will compute to auto (the initial value for flex-basis).

What else we can do then? Set a fallback! As we learned before, a var() function containing a reference to the undeclared variable, computes to its fallback value, as long as it’s specified. So, in this case, we can just set a fallback to the --width-tablet variable:

.column {
	--columns: 12; /* Number of columns in the grid system */
	--width: var(--width-mobile, 0); /* Default width of the element */
	
	flex-basis: calc(var(--width) / var(--columns) * 100%);
}

@media (min-width: 576px) {
	.column {
		--width: var(--width-tablet, var(--width-mobile, 0));
	}
}

See the Pen
#9 Building responsive features with CSS custom properties
by Mikołaj (@mikolajdobrucki)
on CodePen.

This will create a chain of fallback values, making the --width property use --width-tablet when available, then --width-mobile if --width-tablet is not declared, and eventually, 0 if neither of the variables is declared. This approach allows us to perform numerous combinations:

.section-1 {
	/* Flexible on all resolutions */
}

.section-2 {
	/* Full-width on mobile, half of the container's width on tablet and up */
	--width-mobile: 12;
	--width-tablet: 6;
}
	
.section-3 {
	/* Full-width on all resolutions */
	--width-mobile: 12;
}
	
.section-4 {
	/* Flexible on mobile, 25% of the container's width on tablet and up */
	--width-tablet: 3;
}

One more thing we can do here is convert the default 0 value to yet another variable so we avoid repetition. It makes the code a bit longer but easier to update:

.column {
	--columns: 12; /* Number of columns in the grid system */
	--width-default: 0; /* Default width, makes it flexible */
	--width: var(--width-mobile, var(--width-default)); /* Width of the element */
	
	flex-basis: calc(var(--width) / var(--columns) * 100%);
}

@media (min-width: 576px) {
	.column {
		--width: var(--width-tablet, var(--width-mobile, var(--width-default)));
	}
}

See the Pen
#10 Building responsive features with CSS custom properties
by Mikołaj (@mikolajdobrucki)
on CodePen.

Now, we have a fully functional, flexible grid! How about adding some more breakpoints?

Adding more breakpoints

Our grid is already quite powerful but we often need more than one breakpoint. Fortunately, adding more breakpoints to our code couldn’t be easier. All we have to do is to re-use the code we already have and add one variable more:

.column {
	--columns: 12; /* Number of columns in the grid system */
	--width-default: 0; /* Default width, makes it flexible */
	--width: var(--width-mobile, var(--width-default)); /* Width of the element */
	
	flex-basis: calc(var(--width) / var(--columns) * 100%);
}

@media (min-width: 576px) {
	.column {
		--width: var(--width-tablet, var(--width-mobile, var(--width-default)));
	}
}

@media (min-width: 768px) {
	.column {
		--width: var(--width-desktop, var(--width-tablet, var(--width-mobile, var(--width-default))));
	}
}

See the Pen
#11 Building responsive features with CSS custom properties
by Mikołaj (@mikolajdobrucki)
on CodePen.

Reducing fallback chains

One thing that doesn’t look that great in our code is that feedback chains are getting longer and longer with every breakpoint. If we’d like to tackle this issue, we can change our approach to something like this:

.column {
	--columns: 12; /* Number of columns in the grid system */
	--width: var(--width-mobile, 0); /* Width of the element */
	
	flex-basis: calc(var(--width) / var(--columns) * 100%);
}

@media (min-width: 576px) {
	.column {
		--width-tablet: var(--width-mobile);
		--width: var(--width-tablet);
	}
}

@media (min-width: 768px) {
	.column {
		--width-desktop: var(--width-tablet);
		--width: var(--width-desktop);
	}
}

See the Pen
#12 Building responsive features with CSS custom properties
by Mikołaj (@mikolajdobrucki)
on CodePen.

This code is doing exactly the same job but in a bit different way. Instead of creating a full fallback chain for each breakpoint, we set a value of each variable to the variable from the previous breakpoint as a default value.

Why so complicated?

It looks like we’ve done quite a lot of work to complete a relatively simple task. Why? The main answer is: to make the rest of our code simpler and more maintainable. In fact, we could build the same layout by using the techniques described in the previous part of this article:

.container {
	display: flex;
	flex-wrap: wrap;
	margin: 0 auto;
	max-width: 960px;
}

.column {
	--columns: 12; /* Number of columns in the grid system */
	--width: 0; /* Default width of the element */

	flex-basis: calc(var(--width) / var(--columns) * 100%);
}

.header {
	--width: 12;
}

.content {
	--width: 12;
}

.sidebar {
	--width: 12;
}

@media (min-width: 576px) {
	.content {
		--width: 6;
	}
	
	.sidebar {
		--width: 6;
	}
}

@media (min-width: 768px) {
	.content {
		--width: 8;
	}
	
	.sidebar {
		--width: 4;
	}
}

In a small project, this approach could work perfectly well. For the more complex solutions, I would suggest considering a more scalable solution though.

Why should I bother anyway?

If the presented code is doing a very similar job to what we can accomplish with preprocessors such as Sass, why should we bother at all? Are custom properties any better? The answer, as usually, is: it depends. An advantage of using Sass is better browser support. However, using custom properties has a few perks too:

  1. It’s plain CSS. In other words, it’s a more standardized, dependable solution, independent from any third parties. No compiling, no package versions, no weird issues. It just works (apart from the browsers where it just doesn’t work).
  2. It’s easier to debug. That’s a questionable one, as one may argue that Sass provides feedback through console messages and CSS does not. However, you can’t view and debug preprocessed code directly in a browser, whilst working with CSS variables, all the code is available (and live!) directly in DevTools.
  3. It’s more maintainable. Custom properties allow us to do things simply impossible with any preprocessor. It allows us to make our variables more contextual and, therefore, more maintainable. Plus, they are selectable by JavaScript, something Sass variables are not.
  4. It’s more flexible. Notice, that the grid system we’ve built is extremely flexible. Would you like to use a 12-column grid on one page and a 15-column grid on another? Be my guest—it’s a matter of a single variable. The same code can be used on both pages. A preprocessor would require generating code for two separate grid systems.
  5. It takes less space. As long as the weight of CSS files is usually not the main bottleneck of page load performance, it still goes without saying that we should aim to optimize CSS files when possible. To give a better image of how much can be saved, I made a little experiment. I took the grid system from Bootstrap and rebuilt it from scratch with custom properties. The results are as follows: the basic configuration of the Bootstrap grid generates over 54KB of CSS whilst a similar grid made with custom properties is a mere 3KB. That’s a 94% difference! What is more, adding more columns to the Bootstrap grid makes the file even bigger. With CSS variables, we can use as many columns as we want without affecting the file size at all.

The files can be compressed to minimize the difference a bit. The gzipped Bootstrap grid takes 6.4KB in comparison to 0.9KB for the custom properties grid. This is still an 86% difference!

Performance of CSS variables

Summing up, using CSS custom properties has a lot of advantages. But, if we are making the browser do all the calculations that had been done by preprocessors, are we negatively affecting the performance of our site? It’s true that using custom properties and calc() functions will use more computing power. However, in cases similar to the examples we discussed in this article, the difference will usually be unnoticeable. If you’d like to learn more about this topic, I would recommend reading this excellent article by Lisi Linhart.

Not only grid systems

After all, understanding the ins and outs of custom properties may not be as easy as it seems. It will definitely take time, but it’s worth it. CSS variables can be a huge help when working on reusable components, design systems, theming and customizable solutions. Knowing how to deal with fallback values and undeclared variables may turn out to be very handy then.

Thanks for reading and good luck on your own journey with CSS custom properties!


Responsive Designs and CSS Custom Properties: Building a Flexible Grid System originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
https://css-tricks.com/responsive-designs-and-css-custom-properties-building-a-flexible-grid-system/feed/ 4 282899
Responsive Designs and CSS Custom Properties: Defining Variables and Breakpoints https://css-tricks.com/responsive-designs-and-css-custom-properties-defining-variables-and-breakpoints/ https://css-tricks.com/responsive-designs-and-css-custom-properties-defining-variables-and-breakpoints/#comments Mon, 25 Feb 2019 15:22:19 +0000 http://css-tricks.com/?p=282893 CSS custom properties (a.k.a. CSS variables) are becoming more and more popular. They finally reached decent browser support and are slowly making their way into various production environments. The popularity of custom properties shouldn’t come as a surprise, because …


Responsive Designs and CSS Custom Properties: Defining Variables and Breakpoints originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
CSS custom properties (a.k.a. CSS variables) are becoming more and more popular. They finally reached decent browser support and are slowly making their way into various production environments. The popularity of custom properties shouldn’t come as a surprise, because they can be really helpful in numerous use cases, including managing color palettes, customizing components, and theming. But CSS variables can also be really helpful when it comes to responsive design.

Article Series:

  1. Defining Variables and Breakpoints (This Post)
  2. Building a Flexible Grid System

Let’s consider an <article> element with a heading and a paragraph inside:

<article class="post">
	<h2 class="heading">Post's heading</h2>
	<p class="paragraph">
		Lorem ipsum dolor sit amet, consectetur adipisicing elit.
		Laudantium numquam adipisci recusandae officiis dolore tenetur,
		nisi, beatae praesentium, soluta ullam suscipit quas?
	</p>
</article>

It’s a common scenario in such a case to change some sizes and dimensions depending on the viewport’s width. One way to accomplish this is by using media queries:

.post {
	padding: 0.5rem 1rem;
	margin: 0.5rem auto 1rem;
}

.heading {
	font-size: 2rem;
}

@media (min-width: 576px) {
	.post {
		padding: 1rem 2rem;
		margin: 1rem auto 2rem;
	}
	
	.heading {
		font-size: 3rem;
	}
}

See the Pen
#1 Building responsive features with CSS custom properties
by Mikołaj (@mikolajdobrucki)
on CodePen.

Such an approach gives us an easy way to control CSS properties on different screen sizes. However, it may be hard to maintain as the complexity of a project grows. When using media queries, keeping code readable and DRY at the same time quite often turns out to be challenging.

The most common challenges when scaling this pattern include:

  • Repeated selectors: Apart from bloating code with multiple declarations, it also makes future refactoring more difficult, e.g. every time a class name changes it requires remembering to update it in multiple places.
  • Repeated properties: Notice that when overwriting CSS rules within media queries, it requires repeating the entire declaration (e.g. font-size: 3rem;) even though it’s just the value (3rem) that actually changes.
  • Repeated media queries: To keep responsive styles contextual, it’s a common practice to include the same media queries in multiple places, close to the styles they override. Unfortunately, it not only makes code heavier, but also might make breakpoints much harder to maintain. On the other hand, keeping all responsive styles in one place, away from their original declarations, may be very confusing: we end up with multiple references to the same elements sitting in completely different places.

We can argue that repeated declarations and queries shouldn’t be such a big deal with proper file compression enabled, at least as long as we’re referring to performance. We can also merge multiple queries and optimize your code with post-processing tools. But wouldn’t it be easier to avoid these issues altogether?

There’s a lot of ways to avoid the issues listed above. One of them, that we will explore in this article, is to use CSS custom properties.

Using CSS variables for property values

There are plenty of amazing articles on the web explaining the concept of CSS custom properties. If you haven’t got chance to get familiar with them yet, I would recommend starting with one of the beginner articles on this topic such as this awesome piece by Serg Hospodarets as we are not going to get into details of the basic usage in this article.

The most common way of utilizing CSS custom properties in responsive design is to use variables to store values that change inside of media queries. To accomplish this, declare a variable that holds a value that is supposed to change, and then reassign it inside of a media query:

:root {
  --responsive-padding: 1rem;
}

@media (min-width: 576px) {                             
  :root {
    --responsive-padding: 2rem;
  }
}

.foo {
	padding: var(--responsive-padding);
}

Assigning variables to the :root selector is not always a good idea. Same as in JavaScript, having many global variables is considered a bad practice. In real life, try to declare the custom properties in the scope they will actually be used.

This way, we are avoiding multiple rules of the .foo class. We are also separating the logic (changing values) from the actual designs (CSS declarations). Adapting this approach in our example from above gives us the following CSS:

.post {
	--post-vertical-padding: 0.5rem;
	--post-horizontal-padding: 1rem;
	--post-top-margin: 0.5rem;
	--post-bottom-margin: 1rem;
	--heading-font-size: 2rem;
}

@media (min-width: 576px) {
	.post {
		--post-vertical-padding: 1rem;
		--post-horizontal-padding: 2rem;
		--post-top-margin: 1rem;
		--post-bottom-margin: 2rem;
		--heading-font-size: 3rem;
	}
}

.post {
	padding: var(--post-vertical-padding) var(--post-horizontal-padding);
	margin: var(--post-top-margin) auto  var(--post-bottom-margin);
}

.heading {
	font-size: var(--heading-font-size);
}

See the Pen
#2 Building responsive features with CSS custom properties
by Mikołaj (@mikolajdobrucki)
on CodePen.

Notice that the use of variables in shorthand properties (e.g. padding, margin or font) allow some very interesting repercussions. As custom properties may hold almost any value (more on this later), even an empty string, it’s unclear how the value of a shorthand property will be separated out into longhand properties that are used in the cascade later. For example, the auto used in the margin property above may turn out to be a top-and-bottom margin, a left-and-right margin, a top margin, a right margin, a bottom margin or a left margin — it all depends on the values of the custom properties around.

It’s questionable whether the code looks cleaner than the one from the previous example, but on a larger scale, it’s definitely more maintainable. Let’s try to simplify this code a bit now.

Notice that some values are repeated here. What if we try to merge duplicate variables together? Let’s consider the following alteration:

:root {
	--small-spacing: 0.5rem;
	--large-spacing: 1rem;
	--large-font-size: 2rem;
}

@media (min-width: 576px) {
	:root {
		--small-spacing: 1rem;
		--large-spacing: 2rem;
		--large-font-size: 3rem;
	}
}

.post {
	padding: var(--small-spacing) var(--large-spacing);
	margin: var(--small-spacing) auto  var(--large-spacing);
}

.heading {
	font-size: var(--large-font-size);
}

See the Pen
#3 Building responsive features with CSS custom properties
by Mikołaj (@mikolajdobrucki)
on CodePen.

It looks cleaner but is it actually better? Not necessarily. For the sake of flexibility and readability, this may not be the right solution in every case. We definitely shouldn’t merge some variables just because they accidentally turned out to hold the same values. Sometimes, as long as we’re doing this as a part of a well thought out system, it may help us simplify things and preserve consistency across the project. However, in other cases, such a manner may quickly prove to be confusing and problematic. Now, let’s take a look at yet another way we can approach this code.

Using CSS variables as multipliers

CSS custom properties are a fairly new feature to the modern web. One of the other awesome features that rolled out in the last years is the calc() function. It lets us perform real math operations in live CSS. In terms of the browser support, it’s supported in all browsers that support CSS custom properties.

calc() tends to play very nicely with CSS variables, making them even more powerful. This means we can both use calc() inside custom properties and custom properties inside calc()!

For example, the following CSS is perfectly valid:

:root {
	--size: 2;
}
	
.foo {
	--padding: calc(var(--size) * 1rem); /* 2 × 1rem = 2rem */
	padding: calc(var(--padding) * 2);   /* 2rem × 2 = 4rem */
}

Why does this matter to us and our responsive designs? It means that we can use a calc() function to alter CSS custom properties inside media queries. Let’s say we have a padding that should have a value of 5px on mobile and 10px on desktop. Instead of declaring this property two times, we can assign a variable to it and multiply it by two on larger screens:

:root {
	--padding: 1rem;
	--foo-padding: var(--padding);
}

@media (min-width: 576px) {                             
	:root {
		--foo-padding: calc(var(--padding) * 2);
	}
}

.foo {
	padding: var(--foo-padding);
}

Looks fine, however all the values (--padding, calc(--padding * 2)) are away from their declaration (padding). The syntax may also be pretty confusing with two different padding variables (--padding and --foo-padding) and an unclear relationship between them.

To make things a bit clearer, let’s try to code it the other way around:

:root {
	--multiplier: 1;
}

@media (min-width: 576px) {                             
	:root {
		--multiplier: 2;
	}
}

.foo {
	padding: calc(1rem * var(--multiplier));
}

This way, we accomplished the same computed output with much cleaner code! So, instead of using a variable for an initial value of the property (1rem), a variable was used to store a multiplier (1 on small screens and 2 on larger screens). It also allows us to use the --multiplier variable in other declarations. Let’s apply this technique to paddings and margins in our previous snippet:

:root {
	--multiplier: 1;
}

@media (min-width: 576px) {
	:root {
		--multiplier: 2;
	}
}

.post {
	padding: calc(.5rem * var(--multiplier))
						calc(1rem  * var(--multiplier));
	margin:  calc(.5rem * var(--multiplier))
						auto
						calc(1rem  * var(--multiplier));
}

Now, let’s try to implement the same approach with typography. First, we’ll add another heading to our designs:

<h1 class="heading-large">My Blog</h1>
<article class="post">
	<h2 class="heading-medium">Post's heading</h2>
	<p class="paragraph">
		Lorem ipsum dolor sit amet, consectetur adipisicing elit.
		Laudantium numquam adipisci recusandae officiis dolore tenetur,
		nisi, beatae praesentium, soluta ullam suscipit quas?
	</p>
</article>

With multiple text styles in place, we can use a variable to control their sizes too:

:root {
	--headings-multiplier: 1;
}

@media (min-width: 576px) {
	:root {
		--headings-multiplier: 3 / 2;
	}
}

.heading-medium {
	font-size: calc(2rem * var(--headings-multiplier))
}

.heading-large {
	font-size: calc(3rem * var(--headings-multiplier))
}

You may have noticed that 3 / 2 is not a valid CSS value at all. Why does it not cause an error then? The reason is that the syntax for CSS variables is extremely forgiving, which means almost anything can be assigned to a variable, even if it’s not a valid CSS value for any existing CSS property. Declared CSS custom properties are left almost entirely un-evaluated until they are computed by a user agent in certain declarations. So, once a variable is used in a value of some property, this value will turn valid or invalid at the computed-value time.

Oh, and another note about that last note: in case you’re wondering, I used a value of 3 / 2 simply to make a point. In real life, it would make more sense to write 1.5 instead to make the code more readable.

Now, let’s take a look at the finished live example combining everything that we discussed above:

See the Pen
#4 Building responsive features with CSS custom properties
by Mikołaj (@mikolajdobrucki)
on CodePen.

Again, I would never advocate for combining calc() with custom properties to make the code more concise as a general rule. But I can definitely imagine scenarios in which it helps to keep code more organized and maintainable. This approach also allows the weight of CSS to be significantly reduced, when it’s used wisely.

In terms of readability, we can consider it more readable once the underlying rule is understood. It helps to explain the logic and relations between values. On the other hand, some may see it as less readable, because it’s tough to instantly read what a property holds as a value without first doing the math. Also, using too many variables and calc() functions at once may unnecessarily obscure code and make it harder to understand, especially for juniors and front-end developers who are not focused on CSS.

Conclusion

Summing up, there’s a lot of ways to use CSS custom properties in responsive design, definitely not limited to the examples shown above. CSS variables can be used simply to separate the values from the designs. They can also be taken a step further and be combined with some math. None of the presented approaches is better nor worse than the others. The sensibility of using them depends on the case and context.

Now that you know how CSS custom properties can be used in responsive design, I hope you will find a way to introduce them in your own workflow. Next up, we’re going to look at approaches for using them in reusable components and modules, so let’s check that out.


Responsive Designs and CSS Custom Properties: Defining Variables and Breakpoints originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
https://css-tricks.com/responsive-designs-and-css-custom-properties-defining-variables-and-breakpoints/feed/ 6 282893
CSS Variables + calc() + rgb() = Enforcing High Contrast Colors https://css-tricks.com/css-variables-calc-rgb-enforcing-high-contrast-colors/ https://css-tricks.com/css-variables-calc-rgb-enforcing-high-contrast-colors/#comments Thu, 21 Feb 2019 16:26:37 +0000 http://css-tricks.com/?p=282450 As you may know, the recent updates and additions to CSS are extremely powerful. From Flexbox to Grid, and — what we’re concerned about here — Custom Properties (aka CSS variables), all of which make robust and dynamic …


CSS Variables + calc() + rgb() = Enforcing High Contrast Colors originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
As you may know, the recent updates and additions to CSS are extremely powerful. From Flexbox to Grid, and — what we’re concerned about here — Custom Properties (aka CSS variables), all of which make robust and dynamic layouts and interfaces easier than ever while opening up many other possibilities we used to only dream of.

The other day, I was thinking that there must be a way to use Custom Properties to color an element’s background while maintaining a contrast with the foreground color that is high enough (using either white or black) to pass WCAG AA accessibility standards.

It’s astonishingly efficient to do this in JavaScript with a few lines of code:

var rgb = [255, 0, 0];

function setForegroundColor() {
  var sum = Math.round(((parseInt(rgb[0]) * 299) + (parseInt(rgb[1]) * 587) + (parseInt(rgb[2]) * 114)) / 1000);
  return (sum > 128) ? 'black' : 'white';
}

This takes the red, green and blue (RGB) values of an element’s background color, multiplies them by some special numbers (299, 587, and 144, respectively), adds them together, then divides the total by 1,000. When that sum is greater than 128, it will return black; otherwise, we’ll get white. Not too bad.

The only problem is, when it comes to recreating this in CSS, we don’t have access to a native if statement to evaluate the sum. So,how can we replicate this in CSS without one?

Luckily, like HTML, CSS can be very forgiving. If we pass a value greater than 255 into the RGB function, it will get capped at 255. Same goes for numbers lower than 0. Even negative integers will get capped at 0. So, instead of testing whether our sum is greater or less than 128, we subtract 128 from our sum, giving us either a positive or negative integer. Then, if we multiply it by a large negative value (e.g. -1,000), we end up with either very large positive or negative values that we can then pass into the RGB function. Like I said earlier, this will get capped to the browser’s desired values.

Here is an example using CSS variables:

:root {
  --red: 28;
  --green: 150;
  --blue: 130;

  --accessible-color: calc(
    (
      (
        (
          (var(--red) * 299) +
          (var(--green) * 587) +
          (var(--blue) * 114)
        ) / 1000
      ) - 128
    ) * -1000
  );
}

.button {
  color:
    rgb(
      var(--accessible-color),
      var(--accessible-color),
      var(--accessible-color)
    );
  background-color:
    rgb(
      var(--red),
      var(--green),
      var(--blue)
    );
}

If my math is correct (and it’s very possible that it’s not) we get a total of 16,758, which is much greater than 255. Pass this total into the rgb() function for all three values, and the browser will set the text color to white.

Throw in a few range sliders to adjust the color values, and there you have it: a dynamic UI element that can swap text color based on its background-color while maintaining a passing grade with WCAG AA.

See the Pen
CSS Only Accessible Button
by Josh Bader (@joshbader)
on CodePen.

Putting this concept to practical use

Below is a Pen showing how this technique can be used to theme a user interface. I have duplicated and moved the --accessible-color variable into the specific CSS rules that require it, and to help ensure backgrounds remain accessible based on their foregrounds, I have multiplied the --accessible-color variable by -1 in several places. The colors can be changed by using the controls located at the bottom-right. Click the cog/gear icon to access them.

See the Pen
CSS Variable Accessible UI
by Josh Bader (@joshbader)
on CodePen.

There are other ways to do this

A little while back, Facundo Corradini explained how to do something very similar in this post. He uses a slightly different calculation in combination with the hsl function. He also goes into detail about some of the issues he was having while coming up with the concept:

Some hues get really problematic (particularly yellows and cyans), as they are displayed way brighter than others (e.g. reds and blues) despite having the same lightness value. In consequence, some colors are treated as dark and given white text despite being extremely bright.

What in the name of CSS is going on?

He goes on to mention that Edge wasn’t capping his large numbers, and during my testing, I noticed that sometimes it was working and other times it was not. If anyone can pinpoint why this might be, feel free to share in the comments.

Further, Ana Tudor explains how using filter + mix-blend-mode can help contrast text against more complex backgrounds. And, when I say complex, I mean complex. She even goes so far as to demonstrate how text color can change as pieces of the background color change — pretty awesome!

Also, Robin Rendle explains how to use mix-blend-mode along with pseudo elements to automatically reverse text colors based on their background-color.

So, count this as yet another approach to throw into the mix. It’s incredibly awesome that Custom Properties open up these sorts of possibilities for us while allowing us to solve the same problem in a variety of ways.


CSS Variables + calc() + rgb() = Enforcing High Contrast Colors originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
https://css-tricks.com/css-variables-calc-rgb-enforcing-high-contrast-colors/feed/ 18 282450
Slice and Dice a Disc with CSS https://css-tricks.com/slice-and-dice-a-disc-with-css/ https://css-tricks.com/slice-and-dice-a-disc-with-css/#comments Sun, 13 Jan 2019 22:05:09 +0000 http://css-tricks.com/?p=279328 I recently came across an interesting sliced disc design. The disc had a diagonal gradient and was split into horizontal slices, offset a bit from left to right. Naturally, I started to think what would the most efficient way of …


Slice and Dice a Disc with CSS originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
I recently came across an interesting sliced disc design. The disc had a diagonal gradient and was split into horizontal slices, offset a bit from left to right. Naturally, I started to think what would the most efficient way of doing it with CSS be.

Screenshot. Shows a diagonal gradient disc that has been split into eight horizontal slices, one on top of the other, with tiny gaps in between them and slightly offset to the left or right (with respect to the vertical axis of the disc) based on parity.
Sliced gradient disc.

The first thought was that this should be doable with border-radius, right? Well, no! The thing with border-radius is that it creates an elliptical corner whose ends are tangent to the edges it joins.

My second thought was to use a circle() clipping path. Well, turns out this solution works like a charm, so let’s take a close look at it!

Note that the following demos won’t work in Edge as Edge doesn’t yet support clip-path on HTML elements. It could all be emulated with nested elements with overflow: hidden in order to have cross-browser support, but, for simplicity, we dissect the clip-path method in this article.

Slicing a disc into equal parts

As far as the HTML structure goes, we generate it with a preprocessor to avoid repetition. First off, we decide upon a number of slices n. Then we pass this number to the CSS as a custom property --n. Finally, we generate the slices in a loop, passing the index of each to the CSS as another custom property --i.

- var n = 8;

style :root { --n: #{n} }

- for(var i = 0; i < n; i++)
  .slice(style=`--i: ${i}`)

Moving on to the CSS, we first decide upon a diameter $d for our disc. This is the width of our slices. The height is the diameter divided by the number of items calc(#{$d}/var(--n)).

In order to be able to tell them apart, we give our slices dummy backgrounds determined by parity.

$d: 20em;

.slice {
  --parity: 0;
  width: $d;
  height: calc(#{$d}/var(--n));
  background: hsl(36, calc(var(--parity)*100%), calc(80% - var(--parity)*30%));
  
  &:nth-of-type(2n) { --parity: 1 }
}

We also position our slices in the middle with a column flex layout on their container (the body in our case).

See the Pen by thebabydino (@thebabydino) on CodePen.

To get the disc shape we use a circle() clipping path having the radius $r equal to half the diameter .5*$d and the central point dead in the middle of the assembly. Since we set this clip-path on the slices, the position of the central point for each slice is relative to the slice itself.

Horizontally, it’s always in the middle, at 50% of the slice. Vertically, it needs to be in the middle of the assembly, so that’s where the total number of items and the item’s index which we’ve passed as CSS variables from the preprocessor code come into play.

In the middle of the assembly means at half the height of the assembly from the top of the assembly. Half the height of the assembly is half the diameter .5*$d, which is equivalent to the radius $r. But this value is relative to the whole assembly and we need one that’s relative to the current slice. In order to get this, we subtract the vertical position of the current slice relative to the assembly, that is, how far the top of the current slice is relative to the top of the assembly.

The first slice (of index --i: 0) is at the very top of the assembly, so the amount we subtract in this case is 0.

The second slice (of index --i: 1) is at one slice height from the top of the assembly (the space occupied by the first slice), so the amount we subtract in this case is 1 slice heights.

The third slice (of index --i: 2) is at two slice heights from the top of the assembly (the space occupied by the first and second slices), so the amount we subtract in this case is 2 slice heights.

In the general case, the amount we subtract for each slice is the slice’s index (--i) multiplied by one slice height.

--h: calc(#{d}/var(--n)); /* slice height */
clip-path: circle($r at 50% calc(#{$r} - var(--i)*var(--h))

See the Pen by thebabydino (@thebabydino) on CodePen.

After doing this, we can offset the slices based on parity.

--sign: calc(1 - 2*var(--parity));
transform: translate(calc(var(--sign)*2%))

We now have our sliced disc!

See the Pen by thebabydino (@thebabydino) on CodePen.

Spacing out the slices

The first thought that comes to mind here is to use a margin on each slice.

See the Pen by thebabydino (@thebabydino) on CodePen.

This may be a good result in some cases, but what if we don’t want our disc to get elongated?

Well, we have the option of limiting the background to the content-box and adding a vertical padding:

box-sizing: border-box;
padding: .125em 0;
background: hsl(36, calc(var(--parity)*100%), calc(80% - var(--parity)*30%)) 
            content-box;

Of course, in this case, we need to make sure box-sizing is set to border-box so that the vertical padding doesn’t add to the height.

See the Pen by thebabydino (@thebabydino) on CodePen.

The one little problem in this case is that it also cuts off the top of the first slice and the bottom of the last slice. This may not be an issue in some cases and we can always reset the padding-top on the :first-of-type and the padding-bottom on the :last-of-type to 0:

.slice {
  /* other styles */
  padding: .125em 0;
  
  &:first-of-type { padding-top: 0 }
  &:last-of-type { padding-bottom: 0 }
}

However, we also have a one-line solution to this problem of creating gaps in between the slices: add a mask on the container!

This mask is a repeating-linear-gradient() which creates transparent stripes of the thickness of the gap $g, repeats itself after a slice height and is limited to the disc diameter $d horizontally and to the disc diameter $d minus a gap $g vertically (so that we don’t mask out the very top and the very bottom as we also did initially with the padding approach).

mask: repeating-linear-gradient(red 0, red calc(var(--h) - #{$g}), 
                                transparent 0, transparent var(--h)) 
        50% calc(50% - #{.5*$g})/ #{$d} calc(#{$d} - #{$g})

Note that in this case we need to set the slice height variable --h on the container as we’re using it for the mask.

See the Pen by thebabydino (@thebabydino) on CodePen.

Continuous background

In order to have a continuous gradient background, we need to give this background a height equal to that of the disc and set its vertical position relative to each slice such that it always starts from the top of the assembly… wherever that may be located relative to the slice.

The top of the first slice (of index --i: 0) coincides with that of the assembly, so our background starts from 0 vertically.

The top of the second slice (of index --i: 1) is 1 slice height below that of the assembly, so its background starts from 1 slice height above vertically. Since the positive direction of the y axis is down, this means our background-position along the y axis is calc(-1*var(--h)) in this case.

The top of the third slice (of index --i: 2) is 2 slice heights below that of the assembly, so its background starts from 2 slice heights above vertically. This makes our background-position along the y axis is calc(-2*var(--h)).

We notice a pattern here: in general, the background-position along the y axis for a slice is calc(-1*var(--i)*var(--h)).

background: 
  linear-gradient(#eccc05, #c26e4c, #a63959, #4e2255, #333f3d)
 
    /* background-position */
    50% calc(-1*var(--i)*var(--h))/ 

    100% $d /* background-size */

See the Pen by thebabydino (@thebabydino) on CodePen.

But if we want a left to right gradient, then our background isn’t continuous anymore, something that becomes really obvious if we tweak the stop positions a bit in order to have abrupt changes:

background: linear-gradient(90deg, 
       #eccc05 33%, #c26e4c 0, #a63959 67%, #4e2255 0, #333f3d) 
 
    /* background-position */
    50% calc(-1*var(--i)*var(--h))/ 

    100% $d /* background-size */

See the Pen by thebabydino (@thebabydino) on CodePen.

In order to fix this issue, we set the offset as a Sass variable $o, set the horizontal background-size to the slice width (100% or $d) plus twice the offset and make sure we attach the background for the slices that move to the left (in the negative direction of the x axis, so by -$o) on the left side of the slice (background-position along the x axis is 0%) and for the slices that move to the right (in the positive direction of the x axis, so by $o) on the right side of the slice (background-position along the x axis is 100%).

$o: 2%;
transform: translate(calc(var(--sign)*#{$o}));
background: linear-gradient(90deg, 
       #eccc05 33%, #c26e4c 0, #a63959 67%, #4e2255 0, #333f3d) 
 
    /* background-position */
    calc((1 - var(--parity))*100%) calc(-1*var(--i)*var(--h))/ 

    calc(100% + #{2*$o}) $d /* background-size */

See the Pen by thebabydino (@thebabydino) on CodePen.

This works for gradients at any angle, as it can be seen in the interactive demo below – drag to change the gradient angle:

See the Pen by thebabydino (@thebabydino) on CodePen.

It also works for images, though in this case we need to remove the second background-size value so the image doesn’t get distorted, which leaves us with the caveat of getting vertical repetition if the image’s aspect ratio is greater than calc(#{$d} + #{2*$o}) : #{$d}. This isn’t the case for the square image we’re using below, but it’s still something to keep in mind.

See the Pen by thebabydino (@thebabydino) on CodePen.

Another thing to note is that above, the top of the image is attached to the top of of the assembly. If we want the middle of the image to be attached to the middle of the assembly, we need to tweak the vertical component of the background-position a bit.

First off, to attach the middle of the image to the middle of a slice, we use a background-position value of 50%. But we don’t want the middle of the image in the middle of each slice, we want it in the middle of the assembly for all slices. We already know the distance from the top of each slice to the vertical midpoint of the whole assembly – it’s the y coordinate of the clipping circle’s central point:

--y: calc(#{$r} - var(--i)*var(--h));
clip-path: circle($r at 50% var(--y))

The distance from the vertical midpoint of each slice to that of the assembly is this value --y minus half a slice’s height. So it results that the background-position we need along the y axis in order to have the vertical midpoint of the image attached to that of the assembly is calc(50% + var(--y) - .5*var(--h)).

See the Pen by thebabydino (@thebabydino) on CodePen.

Incremental slices

This means our slices don’t have the same height anymore. For example, the first one could have a unit height, the second one twice this height, the third one three times this height and so on…

The added heights of all these slices should equal the disc diameter. In other words, we should have the following equality:

h + 2*h + 3*h + ... + n*h = d

This can also be written as:

h*(1 + 2 + 3 + ... + n) = d

which makes it easier to notice something! Within the parenthesis, we have the sum of the first n natural numbers, which is always n*(n + 1)/2!

So our equality becomes:

h*n*(n + 1)/2 = d

This allows us to get the unit height h:

h = 2*d/n/(n + 1)

Applying this to our demo, we have:

--h: calc(#{2*$d}/var(--n)/(var(--n) + 1));
height: calc((var(--i) + 1)*var(--h));

See the Pen by thebabydino (@thebabydino) on CodePen.

Just like in the case of equal slices, the y coordinate of the central point of the clipping circle() is the disc radius $r minus the distance from the top of the assembly to the top of the current slice. This is the sum of the heights of all previous slices.

In the case of the first slice (--i: 0), we have no previous slice, so this sum is 0.

In the case of the second slice (--i: 1), we only have the first slice before and its height is the unit height (--h).

In the case of the third slice (--i: 2), the sum we want is that between the height of the first slice, which equals the unit height and that of the second slice, which is twice the unit height. That’s calc(var(--h) + 2*var(--h)) or calc(var(--h)*(1 + 2)).

In the case of the third slice (--i: 3), the sum is that between the height of the first slice, which equals the unit height, that of the second slice, which is twice the unit height and that of the third slice, which is three times the unit height. That’s calc(var(--h) + 2*var(--h) + 3*var(--h)) or calc(var(--h)*(1 + 2 + 3)).

Now we can see a pattern emerging! For every slice of index --i, we have that the added height of its previous slices is the unit height --h times the sum of the first --i natural numbers (and the sum of the first --i natural numbers is calc(var(--i)*(var(--i) + 1)/2)). This means our clip-path value becomes:

circle($r at 50% calc(var(--h)*var(--i)*(var(--i) + 1)/2))

We add the offset back in and we have the following result:

See the Pen by thebabydino (@thebabydino) on CodePen.

Sadly, having incremental slices means the repeating-linear-gradient() mask method of creating gaps cannot work anymore. What still works however just fine is the vertical padding method and we can set the padding values such that the top one is 0 for the first slice and the bottom one is 0 for the last slice.

padding: 
  calc(var(--i)*#{$g}/var(--n)) /* top */
  0 /* lateral */
  calc((var(--n) - 1 - var(--i))*#{$g}/var(--n)) /* bottom */

See the Pen by thebabydino (@thebabydino) on CodePen.

For a gradient background, the main idea remains the same as in the case of the equal slices. There are just two things we need to take into account.

One, the background-position along the y axis is minus the distance (in absolute value) between the top of the assembly and the top of the current slice. This distance isn’t calc(var(--i)*var(--h)) like in the case of equal slices of height --h anymore. Instead it’s, as computed a bit earlier, calc(var(--i)*(var(--i) + 1)/2*var(--h)). So the background-position along the y axis is calc(-1*var(--i)*(var(--i) + 1)/2*var(--h)).

And two, we want our background clipped to the content-box so that we keep the gaps, but we need to keep the background-origin to its initial value of padding-box so that our gradient stays continuous.

background: 
  linear-gradient(var(--a), 
                #eccc05, #c26e4c, #a63959, #4e2255, #333f3d) 

    /* background-position */
    calc((1 - var(--parity))*100%) /* x component */ 
    calc(-1*var(--i)*(var(--i) + 1)/2*var(--h)) /* y component */ / 

    /* background-size */
    calc(100% + #{2*$o}) $d 

    padding-box /* background-origin */
    content-box /* background-clip */;

See the Pen by thebabydino (@thebabydino) on CodePen.

For an image background whose midpoint is attached to the middle of our assembly, we need to take into account the fact that half a slice height isn’t the same value for all slices anymore. Now the height of a slice is calc((var(--i) + 1)*var(--h)), so this is the value we need to subtract in the formula for the y component of the background-position.

--y: calc(#{$r} - .5*var(--i)*(var(--i) + 1)*var(--h));
background: 
  url(/amur_leopard.jpg) 

    /* background-position */
    calc((1 - var(--parity))*100%) /* x component */
    calc(50% + var(--y) - .5*(var(--i) + 1)*var(--h)) /* y component */ / 

    /* background-size */
    calc(100% + #{2*$o}) 

    padding-box /* background-origin */
    content-box /* background-clip */;
clip-path: circle($r at 50% var(--y));

See the Pen by thebabydino (@thebabydino) on CodePen.

Vertical slices

We can also slice our disc along the other direction. This means removing the flex-direction: column declaration from the container and letting the flex-direction be the initial one (row), switching the width and the height, the x and y coordinates of the circular clipping path’s central point, the direction along which we shift the slices, the dimensions and x and y positions of the masking gradient, which we also need to rotate so that it goes along the x axis.

body {
  /* same as before */
  --w: calc(#{$d}/var(--n));
  mask: repeating-linear-gradient(90deg, 
                                  red 0, red calc(var(--w) - #{$g}), 
                                  transparent 0, transparent var(--w)) 
          calc(50% - #{.5*$g}) 50% / calc(#{$d} - #{$g}) #{$d}
}

.slice {
  /* same as before */
  width: var(--w); height: $d;
  transform: translatey(calc(var(--sign)*2%));
  background: hsl(36, calc(var(--parity)*100%), calc(80% - var(--parity)*30%));
  clip-path: circle($r at calc(#{$r} - var(--i)*var(--w)) 50%)
}

This gives us equal vertical slices with alternating backgrounds:

See the Pen by thebabydino (@thebabydino) on CodePen.

For the gradient case, we need to also reverse the two background dimensions and the background positions along the x and y axes:

background: 
  linear-gradient(135deg, 
      #eccc05 15%, #c26e4c, #a63959, #4e2255, #333f3d 85%) 

    /* background-position */
    calc(-1*var(--i)*var(--w)) calc((1 - var(--parity))*100%)/ 

    #{$d} calc(100% + #{2*$o}) /* background-size */

See the Pen by thebabydino (@thebabydino) on CodePen.

For incremental slices, we combine the incremental case with the vertical case, which means swapping the values we have for the previous incremental case along the two axes:

--w: calc(#{2*$d}/var(--n)/(var(--n) + 1));
width: calc((var(--i) + 1)*var(--w)); height: $d;
clip-path: circle($r at calc(#{$r} - .5*var(--i)*(var(--i) + 1)*var(--w)) 50%);

See the Pen by thebabydino (@thebabydino) on CodePen.

To create the gaps, we use the padding method. But since we’re now in the vertical case, we need horizontal paddings, on the left and on the right and to make sure the padding-left for the first slice is 0 and the padding-right for the last slice is also 0:

box-sizing: border-box;
padding: 
  0 /* top */
  calc((var(--n) - 1 - var(--i))*#{$g}/var(--n)) /* right */
  0 /* bottom */
  calc(var(--i)*#{$g}/var(--n)) /* left */;
background: 
  hsl(36, calc(var(--parity)*100%), calc(80% - var(--parity)*30%)) 
  content-box

See the Pen by thebabydino (@thebabydino) on CodePen.

Finally, we have the gradient case:

background: 
  linear-gradient(135deg, 
      #eccc05 15%, #c26e4c, #a63959, #4e2255, #333f3d 85%)
    
    /* background-position */ 
    calc(-.5*var(--i)*(var(--i) + 1)*var(--w)) /* x component */
    calc((1 - var(--parity))*100%) /* y component */ / 

    /* background-size */
    #{$d} calc(100% + #{2*$o}) 

    padding-box /* background-origin */
    content-box /* background-clip */;

See the Pen by thebabydino (@thebabydino) on CodePen.

2D case

Again, we generate it with a bit of Pug, the total number of items being the product between the number of columns and the number of rows. For simplicity, we keep the number of rows and the number of columns equal.

- var n = 8, m = Math.pow(n, 2);

style :root { --n: #{n}; --i: 0; --j: 0 }
  - for(var i = 1; i < n; i++) {
    | .tile:nth-of-type(#{n}n + #{i + 1}) { --i: #{i} }
    | .tile:nth-of-type(n + #{n*i + 1}) { --j: #{i} }
  - }
- for(var i = 0; i < m; i++)
  .tile

We’ve also passed the column and row indices (--i and --j respectively) to the CSS.

Since we’re in the 2D case, we switch from using a 1D layout (flex) to using a 2D one (grid). We also start with the disc diameter $d and, given the number of columns is equal to that of rows (--n), our disc gets divided into identical tiles of edge length --l: calc(#{$d}/var(--n)).

$d: 20em;

body {
  --l: calc(#{$d}/var(--n));
  display: grid;
  place-content: center;
  grid-template: repeat(var(--n), var(--l))/ repeat(var(--n), var(--l))
}

To create the gaps in between the tiles, we use the padding approach on the .tile elements and combine the horizontal and vertical cases such that we have the padding-top for the first row is 0, the padding-left for the first column is 0, the padding-bottom for the last row is 0 and the padding-right for the last-column is 0.

padding: 
  calc(var(--j)*#{$g}/var(--n)) /* top */
  calc((var(--n) - 1 - var(--i))*#{$g}/var(--n)) /* right */
  calc((var(--n) - 1 - var(--j))*#{$g}/var(--n)) /* bottom */
  calc(var(--i)*#{$g}/var(--n)) /* left */

Note that we’ve used the row index --j for the top to bottom direction (vertical paddings) and the column index --i from the left to right direction (lateral paddings).

To get the disc shape, we again combine the horizontal and vertical cases, using the column index --i to get the x coordinate of the circular clipping path’s central point and the row index --j to get its y coordinate.

clip-path: 
  circle($r at calc(#{$r} - var(--i)*var(--l)) 
               calc(#{$r} - var(--j)*var(--l)))

See the Pen by thebabydino (@thebabydino) on CodePen.

For a gradient background, it’s again combining the horizontal and the vertical cases and taking into account that here we have no offset at this point, which means the background-size is the disc diameter $d along both axes.

background: 
  linear-gradient(135deg, 
      #eccc05 15%, #c26e4c, #a63959, #4e2255, #333f3d 85%) 

    /* background-position */
    calc(-1*var(--i)*var(--l)) 
    calc(-1*var(--j)*var(--l)) / 

    #{$d} #{$d} /* background-size */
    padding-box /* background-origin */
    content-box /* background-clip */

See the Pen by thebabydino (@thebabydino) on CodePen.

For an image background, we remove the second background-size value so we prevent the image from getting stretched if it’s not square. We also adapt the code for attaching the image’s midpoint to that of the grid from the 1D case to the 2D case:

--x: calc(#{$r} - var(--i)*var(--l));
--y: calc(#{$r} - var(--j)*var(--l));
background: url(/amur_leopard.jpg)

    /* background-position */ 
    calc(50% + var(--x) - .5*var(--l)) 
    calc(50% + var(--y) - .5*var(--l)) / 

    #{$d} /* background-size */
    padding-box /* background-origin */ 
    content-box /* background-clip */;
clip-path: circle($r at var(--x) var(--y))

See the Pen by thebabydino (@thebabydino) on CodePen.

In the incremental case, we don’t have the same dimensions for all tiles, so we use auto sizing for grid-template:

body {
  /* same as before */
  grid-template: repeat(var(--n), auto)/ repeat(var(--n), auto)
}

Just like in the 1D case, we start by computing a unit edge length --u:

--u: calc(#{2*$d}/var(--n)/(var(--n) + 1))

We then set incremental dimensions along both axes for our tile elements:

width: calc((var(--i) + 1)*var(--u));
height: calc((var(--j) + 1)*var(--u))

We also need to adapt the coordinates of the clipping circle’s central point to the incremental case:

clip-path: 
  circle($r at calc(#{$r} - .5*var(--i)*(var(--i) + 1)*var(--u)) 
               calc(#{$r} - .5*var(--j)*(var(--j) + 1)*var(--u)))

See the Pen by thebabydino (@thebabydino) on CodePen.

For a gradient background, we adapt the equal tiles version to the incremental case. This means tweaking the background-position as we did before for the incremental slices, only this time we do it along both axes, not just along one:

background: 
  linear-gradient(135deg, 
      #eccc05 15%, #c26e4c, #a63959, #4e2255, #333f3d 85%) 

    /* background-position */
    calc(-.5*var(--i)*(var(--i) + 1)*var(--l)) 
    calc(-.5*var(--j)*(var(--j) + 1)*var(--l)) / 

    #{$d} #{$d} /* background-size */
    padding-box /* background-origin */
    content-box /* background-clip */

See the Pen by thebabydino (@thebabydino) on CodePen.

Finally, we have the image background option for the incremental 2D case:

background: url(/amur_leopard.jpg)  

    /* background-position */
    calc(50% + var(--x) - .5*(var(--i) + 1)*var(--u)) 
    calc(50% + var(--y) - .5*(var(--j) + 1)*var(--u)) / 

    #{$d} /* background-size */
    padding-box /* background-origin */
    content-box /* background-clip */

See the Pen by thebabydino (@thebabydino) on CodePen.

There are probably more variations we could be coming up with, but we’ll stop here. If you have more ideas on how to push this further, I’d love to hear about them!


Slice and Dice a Disc with CSS originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
https://css-tricks.com/slice-and-dice-a-disc-with-css/feed/ 6 279328
DRY State Switching With CSS Variables: Fallbacks and Invalid Values https://css-tricks.com/dry-state-switching-with-css-variables-fallbacks-and-invalid-values/ Thu, 06 Dec 2018 15:25:56 +0000 http://css-tricks.com/?p=278695 This is the second post in a two-part series that looks into the way CSS variables can be used to make the code for complex layouts and interactions less difficult to write and a lot easier to maintain. The first


DRY State Switching With CSS Variables: Fallbacks and Invalid Values originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
This is the second post in a two-part series that looks into the way CSS variables can be used to make the code for complex layouts and interactions less difficult to write and a lot easier to maintain. The first installment walks through various use cases where this technique applies. This post covers the use of fallbacks and invalid values to extend the technique to non-numeric values.

The strategy of using CSS Variables to drive the switching of layouts and interactions that we covered in the first post in this series comes with one major caveat: it only works with numeric values — lengths, percentages, angles, durations, frequencies, unit-less number values and so on. As a result, it can be really frustrating to know that you’re able to switch the computed values of more than ten properties with a single CSS variable, but then you need to explicitly switch the non-numeric values of properties like flex-direction or text-align from row to column or from left to right or the other way around.

One example would be the one below, where the text-align property depends on parity and the flex-direction depends on whether we are viewing the front end in the wide screen scenario or not.

Screenshot collage. On the left, we have the wide screen scenario, with four paragraphs as the four horizontal, offset based on parity slices of a disc. The slice numbering position is either to the right or left of the actual text content, depending on parity. The text alignment also depends on parity. In the middle, we have the normal screen case. The paragraphs are now full width rectangular elements. On the right, we have the narrow screen case. The paragraph numbering is always above the actual text content in this case.
Screenshot collage.

I complained about this and got a very interesting suggestion in return that makes use of CSS variable fallbacks and invalid values. It was interesting and gives us something new to work with, so let’s start with a short recap of what these are and go from there!

Fallback values

The fallback value of a CSS variable is the second and optional argument of the var() function. For example, let’s consider we have some .box elements whose background is set to a variable of --c:

.box { background: var(--c, #ccc) }

If we haven’t explicitly specified a value for the --c variable elsewhere, then the fallback value #ccc is used.

Now let’s say some of these boxes have a class of .special. Here, we can specify --c as being some kind of orange:

.special { --c: #f90 }

This way, the boxes with this .special class have an orange background, while the others use the light grey fallback.

See the Pen by thebabydino (@thebabydino) on CodePen.

There are a few things to note here.

First off, the fallback can be another CSS variable, which can have a CSS variable fallback itself and… we can fall down a really deep rabbit hole this way!

background: var(--c, var(--c0, var(--c1, var(--c2, var(--c3, var(--c4, #ccc))))))

Secondly, a comma separated list is a perfectly valid fallback value. In fact, everything specified after the first comma inside the var() function constitutes the fallback value, as seen in the example below:

background: linear-gradient(90deg, var(--stop-list, #ccc, #f90))

See the Pen by thebabydino (@thebabydino) on CodePen.

And last, but certainly not least, we can have different fallback values for the same variable used in different places, as illustrated by this example:

$highlight: #f90;

a {
  border: solid 2px var(--c, #{rgba($highlight, 0)})
  color: var(--c, #ccc);
  
  &:hover, &:focus { --c: #{$highlight} }
}

See the Pen by thebabydino (@thebabydino) on CodePen.

Invalid values

First off, I want to clarify what I mean by this. “Invalid values” is shorter and easier to remember, but what it really refers to any value that makes a declaration invalid at computed value time.

For example, consider the following piece of code:

--c: 1em;
background: var(--c)

1em is a valid length value, but this is not a valid value for the background-color property, so here this property will take its initial value (which is transparent) instead.

Putting it all together

Let’s say we have a bunch of paragraphs where we change the lightness of the color value to switch between black and white based on parity (as explained in the previous post in this series):

p {
  --i: 0;
  /* for --i: 0 (odd), the lightness is 0*100% = 0% (black)
   * for --i: 1 (even), the lightness is 1*100% = 100% (white)* /
  color: hsl(0, 0%, calc(var(--i)*100%));

  &:nth-child(2n) { --i: 1 }
}

We also want the odd paragraphs to be right-aligned, while keeping the even ones left-aligned. In order to achieve this, we introduce a --parity variable which we don’t set explicitly in the general case — only for even items. What we do set in the general case is our previous variable, --i. We set it to the value of --parity with a fallback of 0:

p {
  --i: var(--parity, 0);
  color: hsl(0, 0%, calc(var(--i)*100%));

  &:nth-child(2n) { --parity: 1 }
}

So far, this achieves exactly the same as the previous version of our code. However, if we take advantage of the fact that, we can use different fallback values in different places for the same variable, then we can also set text-align to the value of --parity using a fallback of… right!

text-align: var(--parity, right)

In the general case, where we’re not setting --parity explicitly; text-align uses the fallback right, which is a valid value, so we have right alignment. For the even items however, we’re setting --parity explicitly to 1, which is not a valid value for text-align. That means text-align reverts to its initial value, which is left.

See the Pen by thebabydino (@thebabydino) on CodePen.

Now we have right alignment for the odd items and left alignment for the even items while still putting a single CSS variable to use!

Dissecting a more complex example

Let’s consider we want to get the result below:

Screenshot. Shows a bunch of numbered cards. Odd ones have the numbering on the left, while even ones have it on the right. Odd ones are right-aligned, while even ones are left-aligned. Odd ones are shifted a bit to the right and have a bit of a clockwise rotation, while even ones are shifted and rotated by the same amounts, but in the opposite directions. All have a grey to orange gradient background, but for the odd ones, this gradient goes from left to right, while for the even ones it goes from right to left.
Numbered cards where even cards have symmetrical styles with respect to odd cards.

We create these cards with a paragraph element <p> for each one. We switch their box-sizing to border-box, then give them a width, a max-width, a padding and a margin. We also change the default font.

See the Pen by thebabydino (@thebabydino) on CodePen.

We’ve also added a dummy outline just to see the boundaries of these elements.

Next, let’s add the numbering using CSS counters and a :before pseudo-element:

p {
  /* same code as before */
  counter-increment: c;
  
  &:before { content: counter(c, decimal-leading-zero) }
}

See the Pen by thebabydino (@thebabydino) on CodePen.

Now, we’ll give our paragraphs a flex layout and increase the size of the numbering:

p {
  /* same code as before */
  display: flex;
  align-items: center;
  
  &:before {
    font-size: 2em;
    content: counter(c, decimal-leading-zero);
  }
}

See the Pen by thebabydino (@thebabydino) on CodePen.

Now comes the interesting part!

We set a switch --i that changes value with the parity — it’s 0 for the odd items and 1 for the even ones.

p {
  /* same code as before */
  --i: 0;
  
  &:nth-child(2n) { --i: 1 }
}

Next, we want the numbering to be on the left for the odd items and on the right for the even ones. We achieve this via the order property. The initial value for this property is 0, for both the :before pseudo-element and the paragraph’s text content. If we set this order property to 1 for the numbering (the :before pseudo-element) of the even elements, then this moves the numbering after the content.

p {
  /* same code as before */
  --i: 0;
  
  &:before {
    /* same code as before */
    /* we don't really need to set order explicitly as 0 is the initial value */
    order: 0;
  }
  
  &:nth-child(2n) {
    --i: 1;
    
    &:before { order: 1 }
  }
}

You may notice that, in this case, the order value is the same as the switch --i value, so in order to simplify things, we set the order to the switch value.

p {
  /* same code as before */
  --i: 0;
  
  &:before {
    /* same code as before */
    order: var(--i)
  }
  
  &:nth-child(2n) { --i: 1 }
}

See the Pen by thebabydino (@thebabydino) on CodePen.

Now we want a bit of spacing (let’s say $gap) in between the numbers and the paragraph text. This can be achieved with a lateral margin on the :before.

For the odd items, the item numbers are on the left, so we need a non-zero margin-right. For the even items, the item numbers are on the right, so we need a non-zero margin-left.

When the parity switch value is 0 for the odd items, the left margin is 0 = 0*$gap, while the right margin is $gap = 1*$gap = (1 - 0)*$gap.

Similarly for the even items, when the parity switch value is 1, the left margin is $gap = 1*$gap, while the right margin is 0 = 0*$gap = (1 - 1)*$gap.

The result in both cases is that margin-left is the parity switch value times the margin value ($gap), while margin-right is 1 minus the parity switch value, all multiplied with the margin value.

$gap: .75em;

p {
  /* same code as before */
  --i: 0;
  
  &:before {
    /* same code as before */
    margin: 
      0                            /* top */
      calc((1 - var(--i))*#{$gap}) /* right */
      0                            /* bottom */
      calc(var(--i)*#{$gap})       /* left */;
  }
  
  &:nth-child(2n) { --i: 1 }
}

If we use the complementary value (1 - var(--i)) in more than one place, then it’s probably best to set it to another CSS variable --j.

$gap: .75em;

p {
  /* same code as before */
  --i: 0;
  --j: calc(1 - var(--i));
  
  &:before {
    /* same code as before */
    margin: 
      0                      /* top */
      calc(var(--j)*#{$gap}) /* right */
      0                      /* bottom */
      calc(var(--i)*#{$gap}) /* left */;
  }
  
  &:nth-child(2n) { --i: 1 }
}

See the Pen by thebabydino (@thebabydino) on CodePen.

Next, we want to give these items a proper background. This is a grey to orange gradient, going from left to right (or along a 90deg angle) in the case of odd items (parity switch --i: 0) and from right to left (at a -90deg angle) in the case of even items (parity switch --i: 1).

This means the absolute value of the gradient angle is the same (90deg), only the sign is different — it’s +1 for the odd items (--i: 0) and -1 for the even items (--i: 1).

In order to switch the sign, we use the approach we covered in the first post:

/*
 * for --i: 0, we have 1 - 2*0 = 1 - 0 = +1
 * for --i: 1, we have 1 - 2*1 = 1 - 2 = -1
 */
--s: calc(1 - 2*var(--i))

This way, our code becomes:

p {
  /* same code as before */
  --i: 0;
  --s: calc(1 - 2*var(--i));
  background: linear-gradient(calc(var(--s)*90deg), #ccc, #f90);
  
  &:nth-child(2n) { --i: 1 }
}

We can also remove the dummy outline since we don’t need it at this point:

See the Pen by thebabydino (@thebabydino) on CodePen.

Next, we do something similar for the transform property.

The odd items are translated a bit to the right (in the positive direction of the x axis) and rotated a bit in the clockwise (positive) direction, while the even items are translated a bit to the left (in the negative direction of the x axis) and rotated a bit in the other (negative) direction.

The translation and rotation amounts are the same; only the signs differ.

For the odd items, the transform chain is:

translate(10%) rotate(5deg)

While for the even items, we have:

translate(-10%) rotate(-5deg)

Using our sign --s variable, the unified code is:

p {
  /* same code as before */
  --i: 0;
  --s: calc(1 - 2*var(--i));
  transform: translate(calc(var(--s)*10%)) 
             rotate(calc(var(--s)*5deg));
  
  &:nth-child(2n) { --i: 1 }
}

This is now starting to look like something!

See the Pen by thebabydino (@thebabydino) on CodePen.

The next step is to round the card corners. For the odd cards, we want the corners on the left side to be rounded to a radius of half the height. For the even items, we want the corners on the right side to be rounded to the same radius.

Given we don’t know the heights of our cards, we just use a ridiculously large value, say something like 50vh, which gets scaled down to fit due to the way border-radius works. In our case, this means scaled down to whichever is smaller between half the item height (since going vertically has both a top and bottom rounded corner on the same side) and the full item width (since going horizontally has one rounded corner; either on the left or on the right, but not on both the right and the left).

This means we want the corners on the left to have this radius ($r: 50vh) for odd items (--i: 0) and the ones on the right to have the same radius for even items (--i: 1). As a result, we do something pretty similar to the numbering margin case:

$r: 50vh;

p {
  /* same code as before */
  --i: 0;
  --j: calc(1 - var(--i));
  --r0: calc(var(--j)*#{$r});
  --r1: calc(var(--i)*#{$r});
  /* clockwise from the top left */
  border-radius: var(--r0) /* top left */
                 var(--r1) /* top right */
                 var(--r1) /* bottom right */
                 var(--r0) /* bottom left */;
  
  &:nth-child(2n) { --i: 1 }
}

See the Pen by thebabydino (@thebabydino) on CodePen.

Now comes the truly interesting part — text alignment! We want the text in the odd items to be aligned right, while the text in the even items is aligned left. The only problem is that text-align doesn’t take a number value so, no addition or multiplication tricks can help us here.

What can help is combining the use of fallback and invalid values for CSS variables. To do this, we introduce another parity variable --p and it’s this variable that we actually set to 1 for even items. Unlike --i before, we never set --p explicitly for the general case as we want different fallback values of this variable to be used for different properties.

As for --i, we set it to --p with a fallback value of 0. This fallback value of 0 is the value that actually gets used in the general case, since we never explicitly set --p there. For the even case, where we explicitly set --p to 1, --i becomes 1 as well.

At the same time, we set the text-align property to --p with a fallback value of right in the general case. In the even case, where we have --p explicitly set to 1, the text-align value becomes invalid (because we have set text-align to the value of --p and --p is now 1, which is not a valid value for text-align), so the text reverts to being aligned to the left.

p {
  /* same code as before */
  --i: var(--p, 0);
  text-align: var(--p, right);
  
  &:nth-child(2n) { --p: 1 }
}

This gives us the result we’ve been after:

See the Pen by thebabydino (@thebabydino) on CodePen.

Handling responsiveness

While our cards example looks great on wider screens, the same can’t be said when shrink things down.

Screenshot collage. Since the width of the cards depends on the viewport width, the viewport may get too narrow to allow for displaying the numbering and the paragraph text side by side and the right one of the two overflows in this case.
The wide screen result (left) vs. the narrow screen result (right)

In order to fix this, we introduce two more custom properties, --wide and --k to switch between the wide and narrow cases. We set --k to --wide with a fallback value of 0 in the general case and then set --wide to 1 if the viewport width is anything 340px and up.

p {
  /* same code as before */
  --k: var(--wide, 0);
  
  @media (min-width: 340px) { --wide: 1 }
}

Since we only want our items to be transformed and have rounded corners in the wide case, we multiply the translation, rotation and radius values by --k (which is 0, unless the viewport is wide, which switches its value to 1).

p {
  /* same code as before */
  --k: var(--wide, 0);
  --r0: calc(var(--k)*var(--j)*#{$r});
  --r1: calc(var(--k)*var(--i)*#{$r});
  border-radius: var(--r0) /* top left */
                 var(--r1) /* top right */
                 var(--r1) /* bottom right */
                 var(--r0) /* bottom left */;
  transform: translate(calc(var(--k)*var(--s)*10%)) 
             rotate(calc(var(--k)*var(--s)*5deg));

  @media (min-width: 340px) { --wide: 1 }
}

This is slightly better, but our content still overflows in narrow viewports. We can fix this by only placing the numbering (the :before pseudo-element) on the left or right side only in the wide case then moving it above the card in the narrow case.

In order to do this, we multiply both its order and its lateral margin values by --k (which is 1 in the wide case and 0 otherwise).

We also set flex-direction to --wide with a fallback value of column.

This means the flex-direction value is column in the general case (since we haven’t set --wide explicitly elsewhere). However, if the viewport is wide (min-width: 340px), then our --wide variable gets set to 1. But 1 is an invalid value for flex-direction, so this property reverts back to its initial value of row.

p {
  /* same code as before */
  --k: var(--wide, 0);
  flex-direction: var(--wide, column);
  
  &:before {
    /* same code as before */
    order: calc(var(--k)*var(--i));
    margin: 
      0                               /* top */
      calc(var(--k)*var(--j)*#{$gap}) /* right */
      0                               /* bottom */
      calc(var(--k)*var(--i)*#{$gap}) /* left */;
  }
  
  @media (min-width: 340px) { --wide: 1 }
}

Coupled with setting a min-width of 160px on the body, we’ve now eliminated the overflow issue:

Responsive cards, no overflow (live demo).

One more thing we can do is tweak the font-size so that it also depends on --k:

p {
  /* same code as before */
  --k: var(--wide, 0);
  font: 900 calc(var(--k)*.5em + .75em) cursive;

  @media (min-width: 340px) { --wide: 1 }
}

And that’s it, our demo is now nicely responsive!

Responsive cards, font smaller for narrow screens and with no overflow (live demo).

A few more quick examples!

Let’s look at a few more demos that use the same technique, but quickly without building them from scratch. We’ll merely go through the basic ideas behind them.

Disc slices

Sliced disc (live demo).

Just like the cards example we completed together, we can use a :before pseudo-element for the numbering and a flex layout on the paragraphs. The sliced disc effect is achieved using clip-path.

The paragraph elements themselves — the horizontal offsets, the position and intensity of the radial-gradient() creating the shadow effect, the direction of the linear-gradient() and the saturation of its stops, the color and the text alignment — all depend on the --parity variable.

p {
  /* other styles not relevant here */
  --p: var(--parity, 1);
  --q: calc(1 - var(--p));
  --s: calc(1 - 2*var(--p)); /* sign depending on parity */
  transform: translate((calc(var(--i)*var(--s)*#{-$x})));
  background: 
    radial-gradient(at calc(var(--q)*100%) 0, 
      rgba(0, 0, 0, calc(.5 + var(--p)*.5)), transparent 63%) 
      calc(var(--q)*100%) 0/ 65% 65% no-repeat, 
    linear-gradient(calc(var(--s)*-90deg), 
      hsl(23, calc(var(--q)*98%), calc(27% + var(--q)*20%)), 
      hsl(44, calc(var(--q)*92%), 52%));
  color: HSL(0, 0%, calc(var(--p)*100%));
  text-align: var(--parity, right);
	
  &:nth-child(odd) { --parity: 0 }
}

For the numbering (the :before pseudo-elements of the paragraphs), we have that both the margin and the order depend on the --parity in the exact same way as the cards example.

If the viewport width is smaller than the disc diameter $d plus twice the horizontal slice offset in absolute value $x, then we’re not in the --wide case anymore. This affects the width, padding and margin of our paragraphs, as well as their horizontal offset and their shape (because we don’t clip them to get the sliced disc effect at that point).

body {
  /* other styles not relevant here */
  --i: var(--wide, 1);
  --j: calc(1 - var(--i));
	
  @media (max-width: $d + 2*$x) { --wide: 0 }
}

p {
  /* other styles not relevant here */
  margin: calc(var(--j)*.25em) 0;
  padding: 
    calc(var(--i)*#{.5*$r}/var(--n) + var(--j)*5vw) /* vertical */
    calc(var(--i)*#{.5*$r} + var(--j)*2vw) /* horizontal */;
  width: calc(var(--i)*#{$d} /* wide */ + 
              var(--j)*100% /* not wide */);
  transform: translate((calc(var(--i)*var(--s)*#{-$x})));
  clip-path: 
    var(--wide, 
        
      /* fallback, used in the wide case only */
      circle($r at 50% calc((.5*var(--n) - var(--idx))*#{$d}/var(--n))));
}

We’re in the narrow case below 270px and have a flex-direction of column on our paragraphs. We also zero out both the lateral margins and the order for the numbering.

body {
  /* other styles not relevant here */
  --k: calc(1 - var(--narr, 1));
	
  @media (min-width: 270px) { --narr: 0 }
}

p {
  /* other styles not relevant here */
  flex-direction: var(--narr, column);

  &:before {
    /* other styles not relevant here */
    margin: 
      0                             /* top */
      calc(var(--k)*var(--q)*.25em) /* right */
      0                             /* bottom */
      calc(var(--k)*var(--p)*.25em) /* left */;
    order: calc(var(--k)*var(--p));
  }
}

Four-step infographic

Screenshot collage. On the left, there's the wide screen scenario. In the middle, there's the normal screen scenario. On the right, there's the narrow screen scenario.
A four-step infographic (live demo).

This works pretty much the same as the previous two examples. We have a flex layout on our paragraphs using a column direction in the narrow case. We also have a smaller font-size in that same case:

body {
  /* other styles not relevant here */
  --k: var(--narr, 1);
  
  @media (min-width: 400px) { --narr: 0 }
}

p {
  /* other styles not relevant here */
  flex-direction: var(--narr, column);
  font-size: calc((1.25 - .375*var(--k))*1em);
}

The parity determines each paragraph’s text alignment, which lateral border gets a non-zero value, and the position and direction of the border gradient. Both the parity and whether we’re in the wide screen case or not determine the lateral margins and paddings.

body {
  /* other styles not relevant here */
  --i: var(--wide, 1);
  --j: calc(1 - var(--i));
  
  @media (max-width: $bar-w + .5*$bar-h) { --wide: 0 }
}

p {
  /* other styles not relevant here */
  margin: 
    .5em                                 /* top */
    calc(var(--i)*var(--p)*#{.5*$bar-h}) /* right */
    0                                    /* bottom */
    calc(var(--i)*var(--q)*#{.5*$bar-h}) /* left */;
  border-width: 
    0                        /* top */
    calc(var(--q)*#{$bar-b}) /* right */
    0                        /* bottom */
    calc(var(--p)*#{$bar-b}) /* left */;
  padding: 
    $bar-p                                         /* top */
    calc((var(--j) + var(--i)*var(--q))*#{$bar-p}) /* right */
    $bar-p                                         /* bottom */
    calc((var(--j) + var(--i)*var(--p))*#{$bar-p}) /* left */;
  background: 
    linear-gradient(#fcfcfc, gainsboro) padding-box, 
    linear-gradient(calc(var(--s)*90deg), var(--c0), var(--c1)) 
      calc(var(--q)*100%) /* background-position */ / 
      #{$bar-b} 100% /* background-size */;
  text-align: var(--parity, right);
}

The icon is created using the :before pseudo-element, and its order depends on the parity, but only if we’re not in the narrow screen scenario — in which case it’s always before the actual text content of the paragraph. Its lateral margin depends both on the parity and whether we are in the wide screen case or not. The big-valued component that positions it half out of its parent paragraph is only present in the wide screen case. The font-size also depends on whether we’re in the narrow screen case or not (and this influences its em dimensions and padding).

order: calc((1 - var(--k))*var(--p));
margin: 
  0                                                          /* top */
  calc(var(--i)*var(--p)*#{-.5*$ico-d} + var(--q)*#{$bar-p}) /* right */
  0                                                          /* bottom */
  calc(var(--i)*var(--q)*#{-.5*$ico-d} + var(--p)*#{$bar-p}) /* left */;
font-size: calc(#{$ico-s}/(1 + var(--k)));

The ring is created using an absolutely positioned :after pseudo-element (and its placement depends on parity), but only for the wide screen case.

content: var(--wide, '');

The two-dimension case

Screenshot collage. On the left, we have the wide screen scenario. Each article is laid out as a 2x2 grid, with the numbering occupying an entire column, either on the right for odd items or on the left for even items. The heading and the actual text occupy the other column. In the middle, we have the normal screen case. Here, we also have a 2x2 grid, but the numbering occupies only the top row on the same column as before, while the actual text content now spans both columns on the second row. On the right, we have the narrow screen case. In this case, we don't have a grid anymore, the numbering, the heading and the actual text are one under the other for each article.
Screenshot collage (live demo, no Edge support due to CSS variable and calc() bugs).

Here we have a bunch of article elements, each containing a heading. Let’s check out the most interesting aspects of how this responsive layout works!

On each article, we have a two-dimensional layout (grid) — but only if we’re not in the narrow screen scenario (--narr: 1), in which case we fall back on the normal document flow with the numbering created using a :before pseudo-element, followed by the heading, followed by the actual text. In this situation, we also add vertical padding on the heading since we don’t have the grid gaps anymore and we don’t want things to get too crammed.

html {
  --k: var(--narr, 0);
	
  @media (max-width: 250px) { --narr: 1 }
}

article {
  /* other styles irrelevant here */
  display: var(--narr, grid);
}

h3 {
  /* other styles irrelevant here */
  padding: calc(var(--k)*#{$hd3-p-narr}) 0;
}

For the grid, we create two columns of widths depending both on parity and on whether we’re in the wide screen scenario. We make the numbering (the :before pseudo-element) span two rows in the wide screen case, either on the second column or the first, depending on the parity. If we’re not in the wide screen case, then the paragraph spans both columns on the second row.

We set the grid-auto-flow to column dense in the wide screen scenario, letting it revert to the initial value of row otherwise. Since our article elements are wider than the combined widths of the columns and the column gap between them, we use place-content to position the actual grid columns inside at the right or left end depending on parity.

Finally, we place the heading at the end or start of the column, depending on parity, and we as well as the paragraph’s text alignment if we’re in the wide screen scenario.

$col-1-wide: calc(var(--q)*#{$col-a-wide} + var(--p)*#{$col-b-wide});
$col-2-wide: calc(var(--p)*#{$col-a-wide} + var(--q)*#{$col-b-wide});

$col-1-norm: calc(var(--q)*#{$col-a-norm} + var(--p)*#{$col-b-norm});
$col-2-norm: calc(var(--p)*#{$col-a-norm} + var(--q)*#{$col-b-norm});

$col-1: calc(var(--i)*#{$col-1-wide} + var(--j)*#{$col-1-norm});
$col-2: calc(var(--i)*#{$col-2-wide} + var(--j)*#{$col-2-norm});

html {
  --i: var(--wide, 1);
  --j: calc(1 - var(--i));
	
  @media (max-width: $art-w-wide) { --wide: 0 }
}

article {
  /* other styles irrelevant here */
  --p: var(--parity, 1);
  --q: calc(1 - var(--p));
  grid-template-columns: #{$col-1} #{$col-2};
  grid-auto-flow: var(--wide, dense column);
  place-content: var(--parity, center end);
  
  &:before {
    /* other styles irrelevant here */
    grid-row: 1/ span calc(1 + var(--i));
    grid-column: calc(1 + var(--p))/ span 1;
  }
  
  &:nth-child(odd) { --parity: 0 }
}

h3 {
  /* other styles irrelevant here */
  justify-self: var(--parity, self-end);
}

p {
  grid-column-end: span calc(1 + var(--j));
  text-align: var(--wide, var(--parity, right));
}

We also have numerical values such as grid gaps, border radii, paddings, font-sizes, gradient directions, rotation and translation directions depending on the parity and/or whether we’re in the wide screen scenario or not.

Even more examples!

If you want more of this, I’ve created an entire collection of similar responsive demos for you to enjoy!

Screenshot of collection page on CodePen, showing the six most recent demos added.
Collection of responsive demos.

DRY State Switching With CSS Variables: Fallbacks and Invalid Values originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
278695
DRY Switching with CSS Variables: The Difference of One Declaration https://css-tricks.com/dry-switching-with-css-variables-the-difference-of-one-declaration/ https://css-tricks.com/dry-switching-with-css-variables-the-difference-of-one-declaration/#comments Wed, 05 Dec 2018 14:38:34 +0000 http://css-tricks.com/?p=274807 This is the first post of a two-part series that looks into the way CSS variables can be used to make the code for complex layouts and interactions less difficult to write and a lot easier to maintain. This first …


DRY Switching with CSS Variables: The Difference of One Declaration originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
This is the first post of a two-part series that looks into the way CSS variables can be used to make the code for complex layouts and interactions less difficult to write and a lot easier to maintain. This first installment walks through various use cases where this technique applies. The second post covers the use of fallbacks and invalid values to extend the technique to non-numeric values.

What if I told you a single CSS declaration makes the difference in the following image between the wide screen case (left) and the second one (right)? And what if I told you a single CSS declaration makes the difference between the odd and even items in the wide screen case?

On the left, a screenshot of the wide screen scenario. Each item is limited in width and its components are arranged on a 2D 2x2 grid, with the first level heading occupying an entire column, either the one on the right (for odd items) or the one on the left (for even items). The second level heading and the actual text occupy the other column. The shape of the first level heading also varies depending on the parity — it has the top left and the bottom right corners rounded for the odd items and the other two corners rounded for the even items. On the right, a screenshot of the narrower scenario. Each item spans the full viewport width and its components are placed vertically, one under another — first level heading, second level heading below and, finally, the actual text.
Screenshot collage.

Or that a single CSS declaration makes the difference between the collapsed and expanded cases below?

Animated gif. Shows a green button with a magnifier icon. Clicking this button makes it slide right and its background to turn red while a text search field slides out of it to the left and the magnifier morphs into a close (crossmark) icon.
Expanding search.

How is that even possible?

Well, as you may have guessed from the title, it’s all in the power of CSS variables.

There are already plenty of articles out there on what CSS variables are and how to get started with them, so we won’t be getting into that here.

Instead, we’ll dive straight into why CSS variables are useful for achieving these cases and others, then we’ll move on to a detailed explanation of the how for various cases. We’ll code an actual example from scratch, step by step, and, finally, you’ll be getting some eye candy in the form of a few more demos that use the same technique.

So let’s get started!

Why CSS variables are useful

For me, the best thing about CSS variables is that they’ve opened the door for styling things in a logical, mathematical and effortless way.

One example of this is the CSS variable version of the yin and yang loader I coded last year. For this version, we create the two halves with the two pseudo-elements of the loader element.

Animated gif. The yin and yang symbol is rotating while its two lobes alternate increasing and decreasing in size - whenever one is increasing, it squishes the other one down.
Rotating ☯ symbol, with its two lobes increasing and decreasing in size.

We use the same background, border-color, transform-origin and animation-delay values for the two halves. These values all depend on a switch variable --i that’s initially set to 0 on both halves (the pseudo-elements), but then we change it to 1 for the second half (the :after pseudo-element), thus dynamically modifying the computed values of all these properties.

Without CSS variables, we’d have to set all these properties (border-color, transform-origin, background, animation-delay) again on the :after pseudo-element and risk making some typo or even forgetting to set some of them.

How switching works in the general case

Switching between a zero and a non-zero value

In the particular case of the yin and yang loader, all the properties we change between the two halves (pseudo-elements) go from a zero value for one state of the switch and a non-zero value for the other state.

If we want our value to be zero when the switch is off (--i: 0) and non-zero when the switch is on (--i: 1), then we multiply it with the switch value (var(--i)). This way, if our non-zero value should be, let’s say an angular value of 30deg, we have:

  • when the switch is off (--i: 0), calc(var(--i)*30deg) computes to 0*30deg = 0deg
  • when the switch is on (--i: 1), calc(var(--i)*30deg) computes to 1*30deg = 30deg

However, if we want our value to be non-zero when the switch is off (--i: 0) and zero when the switch is on (--i: 1), then we multiply it with the complementary of the switch value (1 - var(--i)). This way, for the same non-zero angular value of 30deg, we have:

  • when the switch is off (--i: 0), calc((1 - var(--i))*30deg) computes to (1 - 0)*30deg = 1*30deg = 30deg
  • when the switch is on (--i: 1), calc((1 - var(--i))*30deg) computes to (1 - 1)*30deg = 0*30deg = 0deg

You can see this concept illustrated below:

Animated gif. Shows how changing the switch value from 0 to 1 changes the rotation of two boxes. The first box is rotated to 30deg when the switch is off (its value is 0) and not rotated or rotated to 0deg when the switch is on (its value is 1). This means we have a rotation value of calc((1 - var(--i))*30deg), where --i is the switch value. The second box is not rotated or rotated to 0deg when the switch is off (its value is 0) and rotated to 30deg when the switch is on (its value is 1). This means we have a rotation value of calc(var(--i)*30deg), with --i being the switch value.
Switching between a zero and a non-zero value (live demo, no Edge support due to calc() not working for angle values)

For the particular case of the loader, we use HSL values for border-color and background-color. HSL stands for hue, saturation, lightness and can be best represented visually with the help of a bicone (which is made up of two cones with the bases glued together).

Two cones with their bases glued together in the middle, one vertex pointing down and one up. The hue is cyclic, distributed around the central (vertical) axis of the bicone. The saturation axis goes horizontally from the central axis towards the surface of the bicone - it's 0% right on the axis and 100% right on the surface. The lightness axis goes vertically from the black vertex to the white one - it's 0% at the black vertex and 100% at the white vertex.
HSL bicone.

The hues go around the bicone, being equivalent to 360° to give us a red in both cases.

Shows the red being at 0° (which is equivalent to 360° since the hue is cyclic), the yellow at 60°, the lime at 120°, the cyan at 180°, the blue at 240° and the magenta at 300°.
Hue wheel.

The saturation goes from 0% on the vertical axis of the bicone to 100% on the bicone surface. When the saturation is 0% (on the vertical axis of the bicone), the hue doesn’t matter anymore; we get the exact same grey for all hues in the same horizontal plane.

The “same horizontal plane” means having the same lightness, which increases along the vertical bicone axis, going from 0% at the black bicone vertex to 100% at the white bicone vertex. When the lightness is either 0% or 100%, neither the hue nor the saturation matter anymore – we always get black for a lightness value of 0% and white for a lightness value of 100%.

Since we only need black and white for our ☯ symbol, the hue and saturation are irrelevant, so we zero them and then switch between black and white by switching the lightness between 0% and 100%.

.yin-yang {
  /* other styles that are irrelevant here */
  
  &:before, &:after {
    /* other styles that are irrelevant here */
    --i: 0;

    /* lightness of border-color when 
     * --i: 0 is (1 - 0)*100% = 1*100% = 100% (white)
     * --i: 1 is (1 - 1)*100% = 0*100% =   0% (black) */
    border: solid $d/6 hsl(0, 0%, calc((1 - var(--i))*100%));

    /* x coordinate of transform-origin when 
     * --i: 0 is 0*100% =   0% (left) 
     * --i: 1 is 1*100% = 100% (right) */
    transform-origin: calc(var(--i)*100%) 50%;

    /* lightness of background-color when 
     * --i: 0 is 0*100% =   0% (black) 
     * --i: 1 is 1*100% = 100% (white) */
    background: hsl(0, 0%, calc(var(--i)*100%));

    /* animation-delay when
     * --i: 0 is 0*-$t = 0s 
     * --i: 1 is 1*-$t = -$t */
    animation: s $t ease-in-out calc(var(--i)*#{-$t}) infinite alternate;
  }
	
  &:after { --i: 1 }
}

Note that this approach doesn’t work in Edge due to the fact that Edge doesn’t support calc() values for animation-delay.

But what if we want to have a non-zero value when the switch is off (--i: 0) and another different non-zero value when the switch is on (--i: 1)?

Switching between two non-zero values

Let’s say we want an element to have a grey background (#ccc) when the switch is off (--i: 0) and an orange background (#f90) when the switch is on (--i: 1).

The first thing we do is switch from hex to a more manageable format such as rgb() or hsl().

We could do this manually either by using a tool such as Lea Verou’s CSS Colors or via DevTools. If we have a background set on an element we can cycle through formats by keeping the Shift key pressed while clicking on the square (or circle) in front of the value in DevTools. This works in both Chrome and Firefox, though it doesn’t appear to work in Edge.

Animated gif. Shows how to cycle through formats (hex/ RGB/ HSL) via DevTools. In both Chrome and Firefox, we do this by keeping the Shift key pressed and clicking the square or circle in front of the <color data-recalc-dims= value.”/>
Changing the format from DevTools.

Even better, if we’re using Sass, we can extract the components with red()/ green()/ blue() or hue()/ saturation()/ lightness() functions.

While rgb() may be the better known format, I tend to prefer hsl() because I find it more intuitive and it’s easier for me to get an idea about what to expect visually just by looking at the code.

So we extract the three components of the hsl() equivalents of our two values ($c0: #ccc when the switch is off and $c1: #f90 when the switch is on) using these functions:

$c0: #ccc;
$c1: #f90;

$h0: round(hue($c0)/1deg);
$s0: round(saturation($c0));
$l0: round(lightness($c0));

$h1: round(hue($c1)/1deg);
$s1: round(saturation($c1));
$l1: round(lightness($c1))

Note that we’ve rounded the results of the hue(), saturation() and lightness() functions as they may return a lot of decimals and we want to keep our generated code clean. We’ve also divided the result of the hue() function by 1deg, as the returned value is a degree value in this case and Edge only supports unit-less values inside the CSS hsl() function. Normally, when using Sass, we can have degree values, not just unit-less ones for the hue inside the hsl() function because Sass treats it as the Sass hsl() function, which gets compiled into a CSS hsl() function with a unit-less hue. But here, we have a dynamic CSS variable inside, so Sass treats this function as the CSS hsl() function that doesn’t get compiled into anything else, so, if the hue has a unit, this doesn’t get removed from the generated CSS.

Now we have that:

  • if the switch is off (--i: 0), our background is
    hsl($h0, $s0, $l0)
  • if the switch is on (--i: 1), our background is
    hsl($h1, $s1, $l1)

We can write our two backgrounds as:

  • if the switch is off (--i: 0),
    hsl(1*$h0 + 0*$h1, 1*$s0 + 0*$s1, 1*$l0 + 1*$l1)
  • if the switch is on (--i: 1),
    hsl(0*$h0 + 1*$h1, 0*$s0 + 1*$s1, 0*$l0 + 1*$l1)

Using the switch variable --i, we can unify the two cases:

--j: calc(1 - var(--i));
background: hsl(calc(var(--j)*#{$h0} + var(--i)*#{$h1}), 
                calc(var(--j)*#{$s0} + var(--i)*#{$s1}), 
                calc(var(--j)*#{$l0} + var(--i)*#{$l1}))

Here, we’ve denoted by --j the complementary value of --i (when --i is 0, --j is 1 and when --i is 1, --j is 0).

Animated gif. Shows how changing the switch value from 0 to 1 changes the background of a box. The background is grey (of hue $h0, saturation $s0 and lightness $l0) when the switch is turned off (its value is zero) and orange (of hue $h1, saturation $s1 and lightness $l1) when the switch is turned on (its value is 1). This means we have a hue value of calc(var(--j)*#{$h0} + var(--i)*#{$h1}), a saturation value of calc(var(--j)*#{$s0} + var(--i)*#{$s1}) and a lightness value of calc(var(--j)*#{$l0} + var(--i)*#{$l1})), where --i is the switch variable.
Switching between two backgrounds (live demo)

The formula above works for switching in between any two HSL values. However, in this particular case, we can simplify it because we have a pure grey when the switch is off (--i: 0).

Purely grey values have equal red, green and blue values when taking into account the RGB model.

When taking into account the HSL model, the hue is irrelevant (our grey looks the same for all hues), the saturation is always 0% and only the lightness matters, determining how light or dark our grey is.

In this situation, we can always keep the hue of the non-grey value (the one we have for the “on” case, $h1).

Since the saturation of any grey value (the one we have for the “off” case, $s0) is always 0%, multiplying it with either 0 or 1 always gives us 0%. So, given the var(--j)*#{$s0} term in our formula is always 0%, we can just ditch it and our saturation formula reduces to the product between the saturation of the “on” case $s1 and the switch variable --i.

This leaves the lightness as the only component where we still need to apply the full formula.

--j: calc(1 - var(--i));
background: hsl($h1, 
                calc(var(--i)*#{$s1}), 
                calc(var(--j)*#{$l0} + var(--i)*#{d1l}))

The above can be tested in this demo.

Similarly, let’s say we want the font-size of some text to be 2rem when our switch is off (--i: 0) and 10vw when the switch is on (--i: 1). Applying the same method, we have:

font-size: calc((1 - var(--i))*2rem + var(--i)*10vw)
Animated gif. Shows how changing the switch value from 0 to 1 changes the font-size.
Switching between two font sizes (live demo)

Alright, let’s now move on to clearing another aspect of this: what is it exactly that causes the switch to flip from on to off or the other way around?

What triggers switching

We have a few options here.

Element-based switching

This means the switch is off for certain elements and on for other elements. For example, this can be determined by parity. Let’s say we want all the even elements to be rotated and have an orange background instead of the initial grey one.

.box {
  --i: 0;
  --j: calc(1 - var(--i));
  transform: rotate(calc(var(--i)*30deg));
  background: hsl($h1, 
                  calc(var(--i)*#{$s1}), 
                  calc(var(--j)*#{$l0} + var(--i)*#{$l1}));
  
  &:nth-child(2n) { --i: 1 }
}
Screenshot. Shows a bunch of squares in a row, the even ones being rotated and having an orange background instead of the initial grey one. This is achieved by making both the transform and the background properties depend on a switch variable --i that changes with parity: it's 0 initially, but then we change it to 1 for even items.
Switching triggered by item parity (live demo, not fully functional in Edge due to calc() not working for angle values)

In the parity case, we flip the switch on for every second item (:nth-child(2n)), but we can also flip it on for every seventh item (:nth-child(7n)), for the first two items (:nth-child(-n + 2)), for all items except the first and last two (:nth-child(n + 3):nth-last-child(n + 3)). We can also flip it on just for headings or just for elements that have a certain attribute.

State-based switching

This means the switch is off when the element itself (or a parent or one of its previous siblings) is in one state and off when it’s another state. In the interactive examples from the previous section, the switch was flipped when a checkbox before our element got checked or unchecked.

We can also have something like a white link that scales up and turns orange when focused or hovered:

$c: #f90;

$h: round(hue($c)/1deg);
$s: round(saturation($c));
$l: round(lightness($c));

a {
  --i: 0;
  transform: scale(calc(1 + var(--i)*.25));
  color: hsl($h, $s, calc(var(--i)*#{$l} + (1 - var(--i))*100%));
  
  &:focus, &:hover { --i: 1 }
}

Since white is any hsl() value with a lightness of 100% (the hue and saturation are irrelevant), we can simplify things by always keeping the hue and saturation of the :focus/ :hover state and only changing the lightness.

Animated gif. Shows a white link that grows and turns orange when hovered or focused.
Switching triggered by state change (live demo, not fully functional in Edge due to calc() values not being supported inside scale() functions)

Media query-based switching

Another possibility is that switching is triggered by a media query, for example, when the orientation changes or when going from one viewport range to another.

Let’s say we have a white heading with a font-size of 1rem up to 320px, but then it turns orange ($c) and the font-size becomes 5vw and starts scaling with the viewport width.

h5 {
  --i: 0;
  color: hsl($h, $s, calc(var(--i)*#{$l} + (1 - var(--i))*100%));
  font-size: calc(var(--i)*5vw + (1 - var(--i))*1rem);
  
  @media (min-width: 320px) { --i: 1 }
}
Animated gif. Shows a heading that's white and has a fixed font-size up to 320px, but as we resize the viewport above that, it becomes orange and its font-size starts scaling with the viewport width.
Switching triggered by viewport change (live demo)

Coding a more complex example from scratch

The example we dissect here is that of the expanding search shown at the beginning of this article, inspired by this Pen, which you should really check out because the code is pretty damn clever.

Animated gif. Shows a green button with a magnifier icon. Clicking this button makes it slide right and its background to turn red while a text search field slides out of it to the left and the magnifier morphs into a close (crossmark) icon.
Expanding search.

Note that from a usability point of view, having such a search box on a website may not be the best idea as one would normally expect the button following the search box to trigger the search, not close the search bar, but it’s still an interesting coding exercise, which is why I’ve chosen to dissect it here.

To begin with, my idea was to do it using only form elements. So, the HTML structure looks like this:

<input id='search-btn' type='checkbox'/>
<label for='search-btn'>Show search bar</label>
<input id='search-bar' type='text' placeholder='Search...'/>

What we do here is initially hide the text input and then reveal it when the checkbox before it gets checked — let’s dive into how that works!

First off, we use a basic reset and set a flex layout on the container of our input and label elements. In our case, this container is the body, but it could be another element as well. We also absolutely position the checkbox and move it out of sight (outside the viewport).

*, :before, :after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
  font: inherit
}

html { overflow-x: hidden }

body {
  display: flex;
  align-items: center;
  justify-content: center;
  margin: 0 auto;
  min-width: 400px;
  min-height: 100vh;
  background: #252525
}

[id='search-btn'] {
  position: absolute;
  left: -100vh
}

So far, so good…

See the Pen by thebabydino (@thebabydino) on CodePen.

So what? We have to admit it’s not exciting at all, so let’s move on to the next step!

We turn the checkbox label into a big round green button and move its text content out of sight using a big negative-valued text-indent and overflow: hidden.

$btn-d: 5em;

/* same as before */

[for='search-btn'] {
  overflow: hidden;
  width: $btn-d;
  height: $btn-d;
  border-radius: 50%;
  box-shadow: 0 0 1.5em rgba(#000, .4);
  background: #d9eb52;
  text-indent: -100vw;
  cursor: pointer;
}

See the Pen by thebabydino (@thebabydino) on CodePen.

Next, we polish the actual search bar by:

  • giving it explicit dimensions
  • providing a background for its normal state
  • defining a different background and a glow for its focused state
  • rounding the corners on the left side using a border-radius that equals half its height
  • Cleaning up the placeholder a bit
$btn-d: 5em;
$bar-w: 4*$btn-d;
$bar-h: .65*$btn-d;
$bar-r: .5*$bar-h;
$bar-c: #ffeacc;

/* same as before */

[id='search-bar'] {
  border: none;
  padding: 0 1em;
  width: $bar-w;
  height: $bar-h;
  border-radius: $bar-r 0 0 $bar-r;
  background: #3f324d;
  color: #fff;
  font: 1em century gothic, verdana, arial, sans-serif;
	
  &::placeholder {
    opacity: .5;
    color: inherit;
    font-size: .875em;
    letter-spacing: 1px;
    text-shadow: 0 0 1px, 0 0 2px
  }
	
  &:focus {
    outline: none;
    box-shadow: 0 0 1.5em $bar-c, 0 1.25em 1.5em rgba(#000, .2);
    background: $bar-c;
    color: #000;
  }
}

See the Pen by thebabydino (@thebabydino) on CodePen.

At this point, the right edge of the search bar coincides with the left edge of the button. However, we want a bit of overlap — let’s say an overlap such that the right edge of the search bar coincides with the button’s vertical midline. Given that we have a flexbox layout with align-items: center on the container (the body in our case), the assembly made up of our two items (the bar and the button) remains middle-aligned horizontally even if we set a margin on one or on the other or on both in between those items. (On the left of the leftmost item or on the right of the rightmost item is a different story, but we won’t be getting into that now.)

Illustration showing the bar plus button assembly in the initial state (bar's right edge coinciding with the button's left edge) vs. the overlap state (the bar's right edge coincides with the button's vertical midline). In both cases, the assembly is middle aligned.
Creating overlap, keeping alignment (live demo).

That’s an overlap of .5*$btn-d minus half a button diameter, which is equivalent to the button’s radius. We set this as a negative margin-right on the bar. We also adjust the padding on the right of the bar so that we compensate for the overlap:

$btn-d: 5em;
$btn-r: .5*$btn-d;

/* same as before */

[id='search-bar'] {
  /* same as before */
  margin-right: -$btn-r;
  padding: 0 calc(#{$btn-r} + 1em) 0 1em;
}

We now have the bar and the button in the positions for the expanded state:

See the Pen by thebabydino (@thebabydino) on CodePen.

Except the bar follows the button in DOM order, so it’s placed on top of it, when we actually want the button on top. Fortunately, this has an easy fix (at least for now — it won’t be enough later, but let’s deal with one issue at a time).

[for='search-btn'] {
  /* same as before */
  position: relative;
}

Now that we’ve given the button a non-static position value, it’s on top of the bar:

See the Pen by thebabydino (@thebabydino) on CodePen.

In this state, the total width of the bar and button assembly is the bar width $bar-w plus the button’s radius $btn-r (which is half the button diameter $btn-d) because we have an overlap for half the button. In the collapsed state, the total width of the assembly is just the button diameter $btn-d.

Illustration showing the bar plus button assembly in the expanded state (the bar's right edge coincides with the button's vertical midline) and in the collapsed state (the bar is collapsed and the assembly is reduced to just the button). In both cases, the assembly is middle aligned.
Expanded vs. collapsed state (live).

Since we want to keep the same central axis when going from the expanded to the collapsed state, we need to shift the button to the left by half the assembly width in the expanded state (.5*($bar-w + $btn-r)) minus the button’s radius ($btn-r).

We call this shift $x and we use it with minus on the button (since we shift the button to the left and left is the negative direction of the x axis). Since we want the bar to collapse into the button, we set the same shift $x on it, but in the positive direction (as we shift the bar to the right of the x axis).

We’re in the collapsed state when the checkbox isn’t checked and in the expanded state when it isn’t. This means our bar and button are shifted with a CSS transform when the checkbox isn’t checked and in the position we currently have them in (no transform) when the checkbox is checked.

In order to do this, we set a variable --i on the elements following our checkbox — the button (created with the label for the checkbox) and the search bar. This variable is 0 in the collapsed state (when both elements are shifted and the checkbox isn’t checked) and 1 in the expanded state (when our bar and button are in the positions they currently occupy, no shift, and the checkbox is checked).

$x: .5*($bar-w + $btn-r) - $btn-r;

[id='search-btn'] {
  position: absolute;
  left: -100vw;
	
  ~ * {
    --i: 0;
    --j: calc(1 - var(--i)) /* 1 when --i is 0, 0 when --i is 1 */
  }
	
  &:checked ~ * { --i: 1 }
}

[for='search-btn'] {
  /* same as before */
  /* if --i is 0, --j is 1 => our translation amount is -$x
   * if --i is 1, --j is 0 => our translation amount is 0 */
  transform: translate(calc(var(--j)*#{-$x}));
}

[id='search-bar'] {
  /* same as before */
  /* if --i is 0, --j is 1 => our translation amount is $x
   * if --i is 1, --j is 0 => our translation amount is 0 */
  transform: translate(calc(var(--j)*#{$x}));
}

And we now have something interactive! Clicking the button toggles the checkbox state (because the button has been created using the label of the checkbox).

See the Pen by thebabydino (@thebabydino) on CodePen.

Except now the button is a bit difficult to click since it’s under the text input again (because we’ve set a transform on the bar and this establishes a stacking context). The fix is pretty straightforward — we need to add a z-index to the button and this moves it above the bar.

[for='search-btn'] {
  /* same as before */
  z-index: 1;
}

See the Pen by thebabydino (@thebabydino) on CodePen.

But we still have another bigger problem: we can see the bar coming out from under the button on the right side. In order to fix this, we set clip-path with an inset() value on the bar. This specifies a clipping rectangle with the help of the distances from the top, right, bottom and left edges of the element’s border-box. Everything outside this clipping rectangle gets cut out and only what’s inside is displayed.

Illustration showing what the four values of the inset() function represent. The first one is the offset of the top edge of the clipping rectangle with respect to the top edge of the border-box. The second one is the offset of the right edge of the clipping rectangle with respect to the right edge of the border-box. The third one is the offset of the bottom edge of the clipping rectangle with respect to the bottom edge of the border-box. The fourth one is the offset of the left edge of the clipping rectangle with respect to the left edge of the border-box. Everything outside the
How the inset() function works (live).

In the illustration above, each distance is going inward from the edges of the border-box. In this case, they’re positive. But they can also go outwards, in which case they’re negative and the corresponding edges of the clipping rectangle are outside the element’s border-box.

At first, you may think we’d have no reason to ever do that, but in our particular case, we do!

We want the distances from the top (dt), bottom (db) and left (dl) to be negative and big enough to contain the box-shadow that extends outside the element’s border-box in the :focus state as we don’t want it to get clipped out. So the solution is to create a clipping rectangle with edges outside the element’s border-box in these three directions.

The distance from the right (dr) is the full bar width $bar-w minus a button radius $btn-r in the collapsed case (checkbox not checked, --i: 0) and 0 in the expanded case (checkbox checked, --i: 1).

$out-d: -3em;

[id='search-bar'] {
  /* same as before */
  clip-path: inset($out-d calc(var(--j)*#{$bar-w - $btn-r}) $out-d $out-d);
}

We now have a search bar and button assembly that expands and collapses on clicking the button.

See the Pen by thebabydino (@thebabydino) on CodePen.

Since we don’t want an abrupt change in between the two states, we use a transition:

[id='search-btn'] {
  /* same as before */
	
  ~ * {
    /* same as before */
    transition: .65s;
  }
}

We also want our button’s background to be green in the collapsed case (checkbox not checked, --i: 0) and pink in the expanded case (checkbox checked, --i: 1). For this, we use the same technique as before:

[for='search-btn'] {
  /* same as before */
  $c0: #d9eb52; // green for collapsed state
  $c1: #dd1d6a; // pink for expanded state
  $h0: round(hue($c0)/1deg);
  $s0: round(saturation($c0));
  $l0: round(lightness($c0));
  $h1: round(hue($c1)/1deg);
  $s1: round(saturation($c1));
  $l1: round(lightness($c1));
  background: hsl(calc(var(--j)*#{$h0} + var(--i)*#{$h1}), 
                  calc(var(--j)*#{$s0} + var(--i)*#{$s1}), 
                  calc(var(--j)*#{$l0} + var(--i)*#{$l1}));
}

Now we’re getting somewhere!

See the Pen by thebabydino (@thebabydino) on CodePen.

What we still need to do is create the icon that morphs between a magnifier in the collapsed state and an “x” in the expanded state to indicate a closing action. We do this with the :before and :after pseudo-elements. We begin by deciding on a diameter for the magnifier and how much of this diameter the width of the icon lines represent.

$ico-d: .5*$bar-h;
$ico-f: .125;
$ico-w: $ico-f*$ico-d;

We absolutely position both pseudo-elements in the middle of the button taking their dimensions into account. We then make them inherit their parent’s transition. We give the :before a background, as this will be the handle of our magnifier, make the :after round with border-radius and give it an inset box-shadow.

[for='search-btn'] {
  /* same as before */
	
  &:before, &:after {
    position: absolute;
    top: 50%; left: 50%;
    margin: -.5*$ico-d;
    width: $ico-d;
    height: $ico-d;
    transition: inherit;
    content: ''
  }
	
  &:before {
    margin-top: -.4*$ico-w;
    height: $ico-w;
    background: currentColor
  }
  
  &:after {
    border-radius: 50%;
    box-shadow: 0 0 0 $ico-w currentColor
  } 
}

We can now see the magnifier components on the button:

See the Pen by thebabydino (@thebabydino) on CodePen.

In order to make our icon to look more like a magnifier, we translate both of its components outwards by a quarter of the magnifier’s diameter. This means translating the handle to the right, in the positive direction of the x axis by .25*$ico-d and the main part to the left, in the negative direction of the x axis by the same .25*$ico-d.

We also scale the handle (the :before pseudo-element) horizontally to half its width with respect to its right edge (which means a transform-origin of 100% along the x axis).

We only want this to happen in the collapsed state (checkbox not checked, --i is 0 and, consequently --j is 1), so we multiply the translation amounts by --j and also use --j to condition the scaling factor:

[for='search-btn'] {
  /* same as before */
	
  &:before {
    /* same as before */
    height: $ico-w;
    transform: 
      /* collapsed: not checked, --i is 0, --j is 1
       * translation amount is 1*.25*$d = .25*$d
       * expanded: checked, --i is 1, --j is 0
       * translation amount is 0*.25*$d = 0 */
      translate(calc(var(--j)*#{.25*$ico-d})) 
      /* collapsed: not checked, --i is 0, --j is 1
       * scaling factor is 1 - 1*.5 = 1 - .5 = .5
       * expanded: checked, --i is 1, --j is 0
       * scaling factor is 1 - 0*.5 = 1 - 0 = 1 */
      scalex(calc(1 - var(--j)*.5))
  }
  
  &:after {
    /* same as before */
    transform: translate(calc(var(--j)*#{-.25*$ico-d}))
  } 
}

We now have thew magnifier icon in the collapsed state:

See the Pen by thebabydino (@thebabydino) on CodePen.

Since we want both icon components to be rotated by 45deg, we add this rotation on the button itself:

[for='search-btn'] {
  /* same as before */
  transform: translate(calc(var(--j)*#{-$x})) rotate(45deg);
}

Now we have the look we want for the collapsed state:

See the Pen by thebabydino (@thebabydino) on CodePen.

This still leaves the expanded state, where we need to turn the round :after pseudo-element into a line. We do this by scaling it down along the x axis and bringing its border-radius from 50% to 0%. The scaling factor we use is the ratio between the width $ico-w of the line we want to get and the diameter $ico-d of the circle it forms in the collapsed state. We’ve called this ratio $ico-f.

Since we only want to do this in the expanded state, when the checkbox is checked and --i is 1, we make both the scaling factor and the border-radius depend on --i and --j:

$ico-d: .5*$bar-h;
$ico-f: .125;
$ico-w: $ico-f*$ico-d;

[for='search-btn'] {
  /* same as before */
	
  &:after{
    /* same as before */
    /* collapsed: not checked, --i is 0, --j is 1
     * border-radius is 1*50% = 50%
     * expanded: checked, --i is 1, --j is 0
     * border-radius is 0*50% = 0 */
    border-radius: calc(var(--j)*50%);
    transform: 
      translate(calc(var(--j)*#{-.25*$ico-d})) 
      /* collapsed: not checked, --i is 0, --j is 1
       * scaling factor is 1 + 0*$ico-f = 1
       * expanded: checked, --i is 1, --j is 0
       * scaling factor is 0 + 1*$ico-f = $ico-f */
      scalex(calc(1 - var(--j)*.5))
  }
}

See the Pen by thebabydino (@thebabydino) on CodePen.

Hmm, almost, but not quite. Scaling has also shrunk our inset box-shadow along the x axis, so let’s fix that with a second inset shadow that we only get in the expanded state (when the checkbox is checked and --i is 1) and therefore, its spread and alpha depend on --i:

$ico-d: .5*$bar-h;
$ico-f: .125;
$ico-w: $ico-f*$ico-d;

[for='search-btn'] {
  /* same as before */
  --hsl: 0, 0%, 0%;
  color: HSL(var(--hsl));
	
  &:after{
    /* same as before */
    box-shadow: 
      inset 0 0 0 $ico-w currentcolor, 
      /* collapsed: not checked, --i is 0, --j is 1
       * spread radius is 0*.5*$ico-d = 0
       * alpha is 0
       * expanded: checked, --i is 1, --j is 0
       * spread radius is 1*.5*$ico-d = .5*$ico-d
       * alpha is 1 */
      inset 0 0 0 calc(var(--i)*#{.5*$ico-d}) HSLA(var(--hsl), var(--i))
  }
}

This gives us our final result!

See the Pen by thebabydino (@thebabydino) on CodePen.

A few more quick examples

The following are a few more demos that use the same technique. We won’t be building these from scratch — we’ll merely go through the basic ideas behind them.

Responsive banners

On the left, a screenshot of the wide screen scenario. In the middle, a screenshot of the normal screen scenario. On the right, a screenshot of the narrow screen scenario.
Screenshot collage (live demo, not fully functional in Edge due to using a calc() value for font-size).

In this case, our actual elements are the smaller rectangles in front, while the number squares and the bigger rectangles in the back are created with the :before and :after pseudo-elements, respectively.

The backgrounds of the number squares are individual and set using a stop list variable --slist that’s different for each item.

<p style='--slist: #51a9ad, #438c92'><!-- 1st paragraph text --></p>
<p style='--slist: #ebb134, #c2912a'><!-- 2nd paragraph text --></p>
<p style='--slist: #db4453, #a8343f'><!-- 3rd paragraph text --></p>
<p style='--slist: #7eb138, #6d982d'><!-- 4th paragraph text --></p>

The things that influence the styles on the banners are the parity and whether we’re in the wide, normal or narrow case. These give us our switch variables:

html {
  --narr: 0;
  --comp: calc(1 - var(--narr));
  --wide: 1;
	
  @media (max-width: 36em) { --wide: 0 }
	
  @media (max-width: 20em) { --narr: 1 }
}

p {
  --parity: 0;
  
  &:nth-child(2n) { --parity: 1 }
}

The number squares are absolutely positioned and their placement depends on parity. If the --parity switch is off (0), then they’re on the left. If it’s on (1), then they’re on the right.

A value of left: 0% aligns with the left edge of the number square along the left edge of its parent, while a value of left: 100% aligns its left edge along the parent’s right edge.

In order to have the right edge of the number square aligned with the right edge of its parent, we need to subtract its own width out of the previous 100% value. (Remember that % values in the case of offsets are relative to the parent’s dimensions.)

left: calc(var(--parity)*(100% - #{$num-d}))

…where $num-d is the size of the numbering square.

In the wide screen case, we also push the numbering outwards by 1em — this means subtracting 1em from the offset we have so far for odd items (having the --parity switch off) and adding 1em to the offset we have so far for even items (having the --parity switch on).

Now the question here is… how do we switch the sign? The simplest way to do it is by using the powers of -1. Sadly, we don’t have a power function (or a power operator) in CSS, even though it would be immensely useful in this case:

/*
 * for --parity: 0, we have pow(-1, 0) = +1
 * for --parity: 1, we have pow(-1, 1) = -1
 */
pow(-1, var(--parity))

This means we have to make it work with what we do have (addition, subtraction, multiplication and division) and that leads to a weird little formula… but, hey, it works!

/*
 * for --parity: 0, we have 1 - 2*0 = 1 - 0 = +1
 * for --parity: 1, we have 1 - 2*1 = 1 - 2 = -1
 */
--sign: calc(1 - 2*var(--parity))

This way, our final formula for the left offset, taking into account both the parity and whether we’re in the wide case (--wide: 1) or not (--wide: 0), becomes:

left: calc(var(--parity)*(100% - #{$num-d}) - var(--wide)*var(--sign)*1em)

We also control the width of the paragraphs with these variables and max-width as we want it to have an upper limit and only fully cover its parent horizontally in the narrow case (--narr: 1):

width: calc(var(--comp)*80% + var(--narr)*100%);
max-width: 35em;

The font-size also depends on whether we’re in the narrow case (--narr: 1) or not (--narr: 0):

calc(.5rem + var(--comp)*.5rem + var(--narr)*2vw)

…and so do the horizontal offsets for the :after pseudo-element (the bigger rectangle in the back) as they’re 0 in the narrow case (--narr: 1) and a non-zero offset $off-x otherwise (--narr: 0):

right: calc(var(--comp)*#{$off-x}); 
left: calc(var(--comp)*#{$off-x});

Hover and focus effects

Animated gif. Shows red diagonal sliding bands covering the white button underneath the black text on hover/focus. On mouseout/ blur, the bands slide out the other way, not the way they entered.
Effect recording (live demo, not fully functional in Edge due to nested calc() bug).

This effect is created with a link element and its two pseudo-elements sliding diagonally on the :hover and :focus states. The link’s dimensions are fixed and so are those of its pseudo-elements, set to the diagonal of their parent $btn-d (computed as the hypotenuse in the right triangle formed by a width and a height) horizontally and the parent’s height vertically.

The :before is positioned such that its bottom left corner coincides to that of its parent, while the :after is positioned such that its top right corner coincides with that of its parent. Since both should have the same height as their parent, the vertical placement is resolved by setting top: 0 and bottom: 0. The horizontal placement is handled in the exact same way as in the previous example, using --i as the switch variable that changes value between the two pseudo-elements and --j, its complementary (calc(1 - var(--i))):

left: calc(var(--j)*(100% - #{$btn-d}))

We set the transform-origin of the :before to its left-bottom corner (0% 100%) and :after to its right-top corner (100% 0%), again, with the help of the switch --i and its complementary --j:

transform-origin: calc(var(--j)*100%) calc(var(--i)*100%)

We rotate both pseudo-elements to the angle between the diagonal and the horizontal $btn-a (also computed from the triangle formed by a height and a width, as the arctangent of the ratio between the two). With this rotation, the horizontal edges meet along the diagonal.

We then shift them outwards by their own width. This means we’ll use a different sign for each of the two, again depending on the switch variable that changes value in between the :before and :after, just like in the previous example with the banners:

transform: rotate($btn-a) translate(calc((1 - 2*var(--i))*100%))

In the :hover and :focus states, this translation needs to go back to 0. This means we multiply the amount of the translation above by the complementary --q of the switch variable --p that’s 0 in the normal state and 1 in the :hover or :focus state:

transform: rotate($btn-a) translate(calc(var(--q)*(1 - 2*var(--i))*100%))

In order to make the pseudo-elements slide out the other way (not back the way they came in) on mouse-out or being out of focus, we set the switch variable --i to the value of --p for :before and to the value of --q for :after, reverse the sign of the translation, and make sure we only transition the transform property.

Responsive infographic

On the left, a screenshot of the wide screen scenario. We have a three row, two column grid with the third row collapsed (height zero). The first level heading occupies either the column on the right (for odd items) or the one on the left (for even items). The second level heading is on the other column and on the first row, while the paragraph text is below the second level heading on the second row. On the right, a screenshot of the narrower scenario. In this case, the third row has a height enough to fit the paragraph text, but the second column is collapsed. The first and second level heading occupy the first and second row respectively.
Screenshot collage with the grid lines and gaps highlighted (live demo, no Edge support due to CSS variable and calc() bugs).

In this case, we have a three-row, two-column grid for each item (article element), with the third row collapsed in the wide screen scenario and the second column collapsed in the narrow screen scenario. In the wide screen scenario, the widths of the columns depend on the parity. In the narrow screen scenario, the first column spans the entire content-box of the element and the second one has width 0. We also have a gap in between the columns, but only in the wide screen scenario.

// formulas for the columns in the wide screen case, where
// $col-a-wide is for second level heading + paragraph
// $col-b-wide is for the first level heading
$col-1-wide: calc(var(--q)*#{$col-a-wide} + var(--p)*#{$col-b-wide});
$col-2-wide: calc(var(--q)*#{$col-b-wide} + var(--p)*#{$col-a-wide});

// formulas for the general case, combining the wide and normal scenarios
$row-1: calc(var(--i)*#{$row-1-wide} + var(--j)*#{$row-1-norm});
$row-2: calc(var(--i)*#{$row-2-wide} + var(--j)*#{$row-2-norm});
$row-3: minmax(0, auto);
$col-1: calc(var(--i)*#{$col-1-wide} + var(--j)*#{$col-1-norm});
$col-2: calc(var(--i)*#{$col-2-wide});

$art-g: calc(var(--i)*#{$art-g-wide});

html {
  --i: var(--wide, 1); // 1 in the wide screen case
  --j: calc(1 - var(--i));

  @media (max-width: $art-w-wide + 2rem) { --wide: 0 }
}

article {
  --p: var(--parity, 0);
  --q: calc(1 - var(--p));
  --s: calc(1 - 2*var(--p));
  display: grid;
  grid-template: #{$row-1} #{$row-2} #{$row-3}/ #{$col-1} #{$col-2};
  grid-gap: 0 $art-g;
  grid-auto-flow: column dense;

  &:nth-child(2n) { --parity: 1 }
}

Since we’ve set grid-auto-flow: column dense, we can get away with only setting the first level heading to cover an entire column (second one for odd items and first one for even items) in the wide screen case and let the second level heading and the paragraph text fill the first free available cells.

// wide case, odd items: --i is 1, --p is 0, --q is 1
// we're on column 1 + 1*1 = 2
// wide case, even items: --i is 1, --p is 1, --q is 0
// we're on column 1 + 1*0 = 1
// narrow case: --i is 0, so var(--i)*var(--q) is 0 and we're on column 1 + 0 = 1
grid-column: calc(1 + var(--i)*var(--q));

// always start from the first row
// span 1 + 2*1 = 3 rows in the wide screen case (--i: 1)
// span 1 + 2*0 = 1 row otherwise (--i: 0)
grid-row: 1/ span calc(1 + 2*var(--i));

For each item, a few other properties depend on whether we’re in the wide screen scenario or not.

The vertical margin, vertical and horizontal padding values, box-shadow offsets and blur are all bigger in the wide screen case:

$art-mv: calc(var(--i)*#{$art-mv-wide} + var(--j)*#{$art-mv-norm});
$art-pv: calc(var(--i)*#{$art-pv-wide} + var(--j)*#{$art-p-norm});
$art-ph: calc(var(--i)*#{$art-ph-wide} + var(--j)*#{$art-p-norm});
$art-sh: calc(var(--i)*#{$art-sh-wide} + var(--j)*#{$art-sh-norm});

article {
  /* other styles */
  margin: $art-mv auto;
  padding: $art-pv $art-ph;
  box-shadow: $art-sh $art-sh calc(3*#{$art-sh}) rgba(#000, .5);
}

We have a non-zero border-width and border-radius in the wide screen case:

$art-b: calc(var(--i)*#{$art-b-wide});
$art-r: calc(var(--i)*#{$art-r-wide});

article {
  /* other styles */
  border: solid $art-b transparent;
  border-radius: $art-r;
}

In the wide screen scenario, we limit the items’ width, but let it be 100% otherwise.

$art-w: calc(var(--i)*#{$art-w-wide} + var(--j)*#{$art-w-norm});

article {
  /* other styles */
  width: $art-w;
}

The direction of the padding-box gradient also changes with the parity:

background: 
  linear-gradient(calc(var(--s)*90deg), #e6e6e6, #ececec) padding-box, 
  linear-gradient(to right bottom, #fff, #c8c8c8) border-box;

In a similar manner, margin, border-width, padding, width, border-radius, background gradient direction, font-size or line-height for the headings and the paragraph text also depend on whether we’re in the wide screen scenario or not (and, in the case of the first level heading’s border-radius or background gradient direction, also on the parity).


DRY Switching with CSS Variables: The Difference of One Declaration originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

]]>
https://css-tricks.com/dry-switching-with-css-variables-the-difference-of-one-declaration/feed/ 3 274807