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<\/a> covers the use of fallbacks and invalid values to extend the technique to non-numeric values.<\/p>\n
<\/p>\n
Or that a single CSS declaration makes the difference between the collapsed and expanded cases below?<\/p>\n
How is that even possible?<\/p>\n
Well, as you may have guessed from the title, it’s all in the power of CSS variables.<\/p>\n
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.<\/p>\n
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<\/em> 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.<\/p>\n
So let’s get started!<\/p>\n
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.<\/p>\n
One example of this is the CSS variable version of the yin and yang loader<\/a> I coded last year. For this version, we create the two halves with the two pseudo-elements of the loader element.<\/p>\n
We use the same background<\/code>,
border-color<\/code>,
transform-origin<\/code> and
animation-delay<\/code> values for the two halves. These values all depend on a switch variable
--i<\/code> that’s initially set to
0<\/code> on both halves (the pseudo-elements), but then we change it to
1<\/code> for the second half (the
:after<\/code> pseudo-element), thus dynamically modifying the computed values of all these properties.<\/p>\n
Without CSS variables, we’d have to set all these properties (
border-color<\/code>,
transform-origin<\/code>,
background<\/code>,
animation-delay<\/code>) again on the
:after<\/code> pseudo-element and risk making some typo or even forgetting to set some of them.<\/p>\n
How switching works in the general case<\/h3>\n
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.<\/p>\n
If we want our value to be zero when the switch is off (--i: 0<\/code>) and non-zero when the switch is on (
--i: 1<\/code>), then we multiply it with the switch value (
var(--i)<\/code>). This way, if our non-zero value should be, let’s say an angular value of
30deg<\/code>, we have:<\/p>\n
\n- when the switch is off<\/strong> (
--i: 0<\/code>), calc(var(--i)*30deg)<\/code> computes to 0*30deg = 0deg<\/code><\/li>\n
when the switch is on<\/strong> (--i: 1<\/code>), calc(var(--i)*30deg)<\/code> computes to 1*30deg = 30deg<\/code><\/li>\n<\/ul>\nHowever, if we want our value to be non-zero when the switch is off (--i: 0<\/code>) and zero when the switch is on (--i: 1<\/code>), then we multiply it with the complementary of the switch value (1 - var(--i)<\/code>). This way, for the same non-zero angular value of 30deg<\/code>, we have:<\/p>\n
\n- when the switch is off<\/strong> (
--i: 0<\/code>), calc((1 - var(--i))*30deg)<\/code> computes to (1 - 0)*30deg = 1*30deg = 30deg<\/code><\/li>\n
when the switch is on<\/strong> (--i: 1<\/code>), calc((1 - var(--i))*30deg)<\/code> computes to (1 - 1)*30deg = 0*30deg = 0deg<\/code><\/li>\n<\/ul>\nYou can see this concept illustrated below:<\/p>\n

Switching between a zero and a non-zero value (live demo<\/a>, no Edge support due to calc()<\/code> not working for angle values)<\/figcaption><\/figure>\nFor the particular case of the loader, we use HSL values for border-color<\/code> and background-color<\/code>. 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).<\/p>\n

HSL bicone.<\/figcaption><\/figure>\nThe hues go around the bicone, 0\u00b0<\/code> being equivalent to 360\u00b0<\/code> to give us a red in both cases.<\/p>\n

Hue wheel.<\/figcaption><\/figure>\nThe saturation goes from 0%<\/code> on the vertical axis of the bicone to 100%<\/code> on the bicone surface. When the saturation is 0%<\/code> (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.<\/p>\n
The “same horizontal plane” means having the same lightness, which increases along the vertical bicone axis, going from 0%<\/code> at the black<\/code> bicone vertex to 100%<\/code> at the white<\/code> bicone vertex. When the lightness is either 0%<\/code> or 100%<\/code>, neither the hue nor the saturation matter anymore – we always get black<\/code> for a lightness value of 0%<\/code> and white<\/code> for a lightness value of 100%<\/code>.<\/p>\n
Since we only need black<\/code> and white<\/code> for our ☯ symbol, the hue and saturation are irrelevant, so we zero them and then switch between black<\/code> and white<\/code> by switching the lightness between 0%<\/code> and 100%<\/code>.<\/p>\n
.yin-yang {\r\n \/* other styles that are irrelevant here *\/\r\n \r\n &:before, &:after {\r\n \/* other styles that are irrelevant here *\/\r\n --i: 0;\r\n\r\n \/* lightness of border-color when \r\n * --i: 0 is (1 - 0)*100% = 1*100% = 100% (white)\r\n * --i: 1 is (1 - 1)*100% = 0*100% = 0% (black) *\/\r\n border: solid $d\/6 hsl(0, 0%, calc((1 - var(--i))*100%));\r\n\r\n \/* x coordinate of transform-origin when \r\n * --i: 0 is 0*100% = 0% (left) \r\n * --i: 1 is 1*100% = 100% (right) *\/\r\n transform-origin: calc(var(--i)*100%) 50%;\r\n\r\n \/* lightness of background-color when \r\n * --i: 0 is 0*100% = 0% (black) \r\n * --i: 1 is 1*100% = 100% (white) *\/\r\n background: hsl(0, 0%, calc(var(--i)*100%));\r\n\r\n \/* animation-delay when\r\n * --i: 0 is 0*-$t = 0s \r\n * --i: 1 is 1*-$t = -$t *\/\r\n animation: s $t ease-in-out calc(var(--i)*#{-$t}) infinite alternate;\r\n }\r\n\t\r\n &:after { --i: 1 }\r\n}<\/code><\/pre>\n
Note that this approach<\/a> doesn’t work in Edge due to the fact that Edge doesn’t support calc()<\/code> values for animation-delay<\/code>.<\/p>\n
Switching between two non-zero values<\/h4>\nLet’s say we want an element to have a grey background<\/code> (#ccc<\/code>) when the switch is off (--i: 0<\/code>) and an orange background<\/code> (#f90<\/code>) when the switch is on (--i: 1<\/code>).<\/p>\n
The first thing we do is switch from hex to a more manageable format such as rgb()<\/code> or hsl()<\/code>.<\/p>\n
We could do this manually either by using a tool such as Lea Verou’s
CSS Colors<\/a> or via DevTools. If we have a background<\/code> set on an element we can cycle through formats by keeping the Shift<\/code> 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.<\/p>\n
value.”\/>
Changing the format from DevTools.<\/figcaption><\/figure>\nEven better, if we’re using Sass, we can extract the components with red()<\/code><\/a>\/
green()<\/code><\/a>\/
blue()<\/code><\/a> or
hue()<\/code><\/a>\/
saturation()<\/code><\/a>\/
lightness()<\/code><\/a> functions.<\/p>\n
$c0: #ccc;\r\n$c1: #f90;\r\n\r\n$h0: round(hue($c0)\/1deg);\r\n$s0: round(saturation($c0));\r\n$l0: round(lightness($c0));\r\n\r\n$h1: round(hue($c1)\/1deg);\r\n$s1: round(saturation($c1));\r\n$l1: round(lightness($c1))<\/code><\/pre>\nNote that we’ve rounded the results of the hue()<\/code>, saturation()<\/code> and lightness()<\/code> 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()<\/code> function by 1deg<\/code>, as the returned value is a degree value in this case and Edge only supports unit-less values inside the CSS<\/em><\/strong> hsl()<\/code> function. Normally, when using Sass, we can have degree values, not just unit-less ones for the hue inside the hsl()<\/code> function because Sass treats it as the Sass hsl()<\/code> function, which gets compiled into a CSS hsl()<\/code> function with a unit-less hue. But here, we have a dynamic CSS variable inside, so Sass treats this function as the CSS hsl()<\/code> 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.<\/p>\n
Now we have that:<\/p>\n
\n- if the switch is off (
--i: 0<\/code>), our background<\/code> is
hsl($h0, $s0, $l0)<\/code><\/li>\n
if the switch is on (--i: 1<\/code>), our background<\/code> is
hsl($h1, $s1, $l1)<\/code><\/li>\n<\/ul>\nWe can write our two backgrounds as:<\/p>\n
\n- if the switch is off (
--i: 0<\/code>),
hsl(1*$h0 + 0*$h1, 1*$s0 + 0*$s1, 1*$l0 + 1*$l1)<\/code><\/li>\n
if the switch is on (--i: 1<\/code>),
hsl(0*$h0 + 1*$h1, 0*$s0 + 1*$s1, 0*$l0 + 1*$l1)<\/code><\/li>\n<\/ul>\nUsing the switch variable --i<\/code>, we can unify the two cases:<\/p>\n
--j: calc(1 - var(--i));\r\nbackground: hsl(calc(var(--j)*#{$h0} + var(--i)*#{$h1}), \r\n calc(var(--j)*#{$s0} + var(--i)*#{$s1}), \r\n calc(var(--j)*#{$l0} + var(--i)*#{$l1}))<\/code><\/pre>\nHere, we’ve denoted by --j<\/code> the complementary value of --i<\/code> (when --i<\/code> is 0<\/code>, --j<\/code> is 1<\/code> and when --i<\/code> is 1<\/code>, --j<\/code> is 0<\/code>).<\/p>\n

Switching between two backgrounds (live demo<\/a>)<\/figcaption><\/figure>\nThe 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<\/code>).<\/p>\n
Purely grey values have equal red, green and blue values when taking into account the RGB model.<\/p>\n
When taking into account the HSL model, the hue is irrelevant (our grey looks the same for all hues), the saturation is always 0%<\/code> and only the lightness matters, determining how light or dark our grey is.<\/p>\n
In this situation, we can always keep the hue of the non-grey value (the one we have for the “on” case, $h1<\/code>). <\/p>\n
Since the saturation of any grey value (the one we have for the “off” case, $s0<\/code>) is always 0%<\/code>, multiplying it with either 0<\/code> or 1<\/code> always gives us 0%<\/code>. So, given the var(--j)*#{$s0}<\/code> term in our formula is always 0%<\/code>, we can just ditch it and our saturation formula reduces to the product between the saturation of the “on” case $s1<\/code> and the switch variable --i<\/code>.<\/p>\n
This leaves the lightness as the only component where we still need to apply the full formula.<\/p>\n
--j: calc(1 - var(--i));\r\nbackground: hsl($h1, \r\n calc(var(--i)*#{$s1}), \r\n calc(var(--j)*#{$l0} + var(--i)*#{d1l}))<\/code><\/pre>\n
The above can be tested in this demo<\/a>.<\/p>\n
font-size: calc((1 - var(--i))*2rem + var(--i)*10vw)<\/code><\/pre>\n

Switching between two font sizes (live demo<\/a>)<\/figcaption><\/figure>\nAlright, 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?<\/p>\n
What triggers switching<\/h3>\n
We have a few options here.<\/p>\n
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<\/code> instead of the initial grey one.<\/p>\n
.box {\r\n --i: 0;\r\n --j: calc(1 - var(--i));\r\n transform: rotate(calc(var(--i)*30deg));\r\n background: hsl($h1, \r\n calc(var(--i)*#{$s1}), \r\n calc(var(--j)*#{$l0} + var(--i)*#{$l1}));\r\n \r\n &:nth-child(2n) { --i: 1 }\r\n}<\/code><\/pre>\n
Switching triggered by item parity (live demo<\/a>, not fully functional in Edge due to calc()<\/code> not working for angle values)<\/figcaption><\/figure>\n
In the parity case, we flip the switch on for every second item (
:nth-child(2n)<\/code>), but we can also flip it on for every seventh item (
:nth-child(7n)<\/code>), for the
first two items<\/a> (
:nth-child(-n + 2)<\/code>), for
all items except the first and last two<\/a> (
:nth-child(n + 3):nth-last-child(n + 3)<\/code>). We can also flip it on just for headings or just for elements that have a certain attribute.<\/p>\n
State-based switching<\/h4>\n
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.<\/p>\n
We can also have something like a white link that scales up and turns orange when focused or hovered:<\/p>\n
$c: #f90;\r\n\r\n$h: round(hue($c)\/1deg);\r\n$s: round(saturation($c));\r\n$l: round(lightness($c));\r\n\r\na {\r\n --i: 0;\r\n transform: scale(calc(1 + var(--i)*.25));\r\n color: hsl($h, $s, calc(var(--i)*#{$l} + (1 - var(--i))*100%));\r\n \r\n &:focus, &:hover { --i: 1 }\r\n}<\/code><\/pre>\n
Since
white<\/code> is any
hsl()<\/code> value with a lightness of
100%<\/code> (the hue and saturation are irrelevant), we can simplify things by always keeping the hue and saturation of the
:focus<\/code>\/
:hover<\/code> state and only changing the lightness.<\/p>\n
Switching triggered by state change (live demo<\/a>, not fully functional in Edge due to calc()<\/code> values not being supported inside
scale()<\/code> functions)<\/figcaption><\/figure>\n
Media query-based switching<\/h4>\n
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.<\/p>\n
Let’s say we have a
white<\/code> heading with a
font-size<\/code> of
1rem<\/code> up to
320px<\/code>, but then it turns orange (
$c<\/code>) and the
font-size<\/code> becomes
5vw<\/code> and starts scaling with the viewport
width<\/code>.<\/p>\n
h5 {\r\n --i: 0;\r\n color: hsl($h, $s, calc(var(--i)*#{$l} + (1 - var(--i))*100%));\r\n font-size: calc(var(--i)*5vw + (1 - var(--i))*1rem);\r\n \r\n @media (min-width: 320px) { --i: 1 }\r\n}<\/code><\/pre>\n
Switching triggered by viewport change (live demo<\/a>)<\/figcaption><\/figure>\n Coding a more complex example from scratch<\/h3>\n
The example we dissect here is that of the expanding search shown at the beginning of this article, inspired by this Pen<\/a>, which you should really check out because the code is pretty damn clever.<\/p>\n
Expanding search.<\/figcaption><\/figure>\n 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.<\/p>\n
To begin with, my idea was to do it using only form elements. So, the HTML structure looks like this:<\/p>\n
<input id='search-btn' type='checkbox'\/>\r\n<label for='search-btn'>Show search bar<\/label>\r\n<input id='search-bar' type='text' placeholder='Search...'\/><\/code><\/pre>\n
What we do here is initially hide the text
input<\/code> and then reveal it when the checkbox before it gets checked — let’s dive into how that works!<\/p>\n
First off, we use a basic reset and set a
flex<\/code> layout on the container of our
input<\/code> and
label<\/code> elements. In our case, this container is the
body<\/code>, but it could be another element as well. We also absolutely position the checkbox and move it out of sight (outside the viewport).<\/p>\n
*, :before, :after {\r\n box-sizing: border-box;\r\n margin: 0;\r\n padding: 0;\r\n font: inherit\r\n}\r\n\r\nhtml { overflow-x: hidden }\r\n\r\nbody {\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n margin: 0 auto;\r\n min-width: 400px;\r\n min-height: 100vh;\r\n background: #252525\r\n}\r\n\r\n[id='search-btn'] {\r\n position: absolute;\r\n left: -100vh\r\n}<\/code><\/pre>\n
So far, so good…<\/p>\n
See the Pen<\/a> by thebabydino (@thebabydino<\/a>) on CodePen<\/a>.<\/p>\n
So what? We have to admit it’s not exciting at all, so let’s move on to the next step!<\/p>\n
$btn-d: 5em;\r\n\r\n\/* same as before *\/\r\n\r\n[for='search-btn'] {\r\n overflow: hidden;\r\n width: $btn-d;\r\n height: $btn-d;\r\n border-radius: 50%;\r\n box-shadow: 0 0 1.5em rgba(#000, .4);\r\n background: #d9eb52;\r\n text-indent: -100vw;\r\n cursor: pointer;\r\n}<\/code><\/pre>\n
See the Pen<\/a> by thebabydino (@thebabydino<\/a>) on CodePen<\/a>.<\/p>\n
Next, we polish the actual search bar by:<\/p>\n
\n
- giving it explicit dimensions<\/li>\n
- providing a
background<\/code> for its normal state<\/li>\n
defining a different
background<\/code> and a glow for its focused state<\/li>\n
rounding the corners on the left side using a
border-radius<\/code> that equals half its
height<\/code><\/li>\n
Cleaning up the placeholder a bit<\/li>\n<\/ul>\n
$btn-d: 5em;\r\n$bar-w: 4*$btn-d;\r\n$bar-h: .65*$btn-d;\r\n$bar-r: .5*$bar-h;\r\n$bar-c: #ffeacc;\r\n\r\n\/* same as before *\/\r\n\r\n[id='search-bar'] {\r\n border: none;\r\n padding: 0 1em;\r\n width: $bar-w;\r\n height: $bar-h;\r\n border-radius: $bar-r 0 0 $bar-r;\r\n background: #3f324d;\r\n color: #fff;\r\n font: 1em century gothic, verdana, arial, sans-serif;\r\n\t\r\n &::placeholder {\r\n opacity: .5;\r\n color: inherit;\r\n font-size: .875em;\r\n letter-spacing: 1px;\r\n text-shadow: 0 0 1px, 0 0 2px\r\n }\r\n\t\r\n &:focus {\r\n outline: none;\r\n box-shadow: 0 0 1.5em $bar-c, 0 1.25em 1.5em rgba(#000, .2);\r\n background: $bar-c;\r\n color: #000;\r\n }\r\n}<\/code><\/pre>\n
See the Pen<\/a> by thebabydino (@thebabydino<\/a>) on CodePen<\/a>.<\/p>\n
Creating overlap, keeping alignment (live demo<\/a>).<\/figcaption><\/figure>\n That’s an overlap of
.5*$btn-d<\/code> minus half a button diameter, which is equivalent to the button’s radius. We set this as a negative
margin-right<\/code> on the bar. We also adjust the
padding<\/code> on the right of the bar so that we compensate for the overlap:<\/p>\n
$btn-d: 5em;\r\n$btn-r: .5*$btn-d;\r\n\r\n\/* same as before *\/\r\n\r\n[id='search-bar'] {\r\n \/* same as before *\/\r\n margin-right: -$btn-r;\r\n padding: 0 calc(#{$btn-r} + 1em) 0 1em;\r\n}<\/code><\/pre>\n
We now have the bar and the button in the positions for the expanded state:<\/p>\n
See the Pen<\/a> by thebabydino (@thebabydino<\/a>) on CodePen<\/a>.<\/p>\n
[for='search-btn'] {\r\n \/* same as before *\/\r\n position: relative;\r\n}<\/code><\/pre>\n
Now that we’ve given the button a non-static
position<\/code> value, it’s on top of the bar:<\/p>\n
See the
Pen<\/a> by thebabydino (@thebabydino<\/a>) on CodePen<\/a>.<\/p>\n
Expanded vs. collapsed state (live<\/a>).<\/figcaption><\/figure>\n 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)<\/code>) minus the button’s radius (
$btn-r<\/code>).<\/p>\n
We call this shift
$x<\/code> 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<\/var> axis). Since we want the bar to collapse into the button, we set the same shift
$x<\/code> on it, but in the positive direction (as we shift the bar to the right of the x<\/var> axis).<\/p>\n
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<\/code> when the checkbox isn’t checked and in the position we currently have them in (no
transform<\/code>) when the checkbox is checked.<\/p>\n
In order to do this, we set a variable
--i<\/code> on the elements following our checkbox — the button (created with the
label<\/code> for the checkbox) and the search bar. This variable is
0<\/code> in the collapsed state (when both elements are shifted and the checkbox isn’t checked) and
1<\/code> in the expanded state (when our bar and button are in the positions they currently occupy, no shift, and the checkbox is checked).<\/p>\n
$x: .5*($bar-w + $btn-r) - $btn-r;\r\n\r\n[id='search-btn'] {\r\n position: absolute;\r\n left: -100vw;\r\n\t\r\n ~ * {\r\n --i: 0;\r\n --j: calc(1 - var(--i)) \/* 1 when --i is 0, 0 when --i is 1 *\/\r\n }\r\n\t\r\n &:checked ~ * { --i: 1 }\r\n}\r\n\r\n[for='search-btn'] {\r\n \/* same as before *\/\r\n \/* if --i is 0, --j is 1 => our translation amount is -$x\r\n * if --i is 1, --j is 0 => our translation amount is 0 *\/\r\n transform: translate(calc(var(--j)*#{-$x}));\r\n}\r\n\r\n[id='search-bar'] {\r\n \/* same as before *\/\r\n \/* if --i is 0, --j is 1 => our translation amount is $x\r\n * if --i is 1, --j is 0 => our translation amount is 0 *\/\r\n transform: translate(calc(var(--j)*#{$x}));\r\n}<\/code><\/pre>\n
And we now have something interactive! Clicking the button toggles the checkbox state (because the button has been created using the
label<\/code> of the checkbox).<\/p>\n
See the
Pen<\/a> by thebabydino (@thebabydino<\/a>) on CodePen<\/a>.<\/p>\n
[for='search-btn'] {\r\n \/* same as before *\/\r\n z-index: 1;\r\n}<\/code><\/pre>\n
See the Pen<\/a> by thebabydino (@thebabydino<\/a>) on CodePen<\/a>.<\/p>\n
How the inset()<\/code> function works (
live<\/a>).<\/figcaption><\/figure>\n
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<\/code>.<\/p>\n
At first, you may think we’d have no reason to ever do that, but in our particular case, we do!<\/p>\n
We want the distances from the top (
dt<\/code>), bottom (
db<\/code>) and left (
dl<\/code>) to be negative and big enough to contain the
box-shadow<\/code> that extends outside the element’s
border-box<\/code> in the
:focus<\/code> 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<\/code> in these three directions.<\/p>\n
The distance from the right (
dr<\/code>) is the full bar width
$bar-w<\/code> minus a button radius
$btn-r<\/code> in the collapsed case (checkbox not checked,
--i: 0<\/code>) and
0<\/code> in the expanded case (checkbox checked,
--i: 1<\/code>).<\/p>\n
$out-d: -3em;\r\n\r\n[id='search-bar'] {\r\n \/* same as before *\/\r\n clip-path: inset($out-d calc(var(--j)*#{$bar-w - $btn-r}) $out-d $out-d);\r\n}<\/code><\/pre>\n
We now have a search bar and button assembly that expands and collapses on clicking the button.<\/p>\n
See the Pen<\/a> by thebabydino (@thebabydino<\/a>) on CodePen<\/a>.<\/p>\n
Since we don’t want an abrupt change in between the two states, we use a
transition<\/code>:<\/p>\n
[id='search-btn'] {\r\n \/* same as before *\/\r\n\t\r\n ~ * {\r\n \/* same as before *\/\r\n transition: .65s;\r\n }\r\n}<\/code><\/pre>\n
We also want our button’s
background<\/code> to be green in the collapsed case (checkbox not checked,
--i: 0<\/code>) and pink in the expanded case (checkbox checked,
--i: 1<\/code>). For this, we use the same technique as before:<\/p>\n
[for='search-btn'] {\r\n \/* same as before *\/\r\n $c0: #d9eb52; \/\/ green for collapsed state\r\n $c1: #dd1d6a; \/\/ pink for expanded state\r\n $h0: round(hue($c0)\/1deg);\r\n $s0: round(saturation($c0));\r\n $l0: round(lightness($c0));\r\n $h1: round(hue($c1)\/1deg);\r\n $s1: round(saturation($c1));\r\n $l1: round(lightness($c1));\r\n background: hsl(calc(var(--j)*#{$h0} + var(--i)*#{$h1}), \r\n calc(var(--j)*#{$s0} + var(--i)*#{$s1}), \r\n calc(var(--j)*#{$l0} + var(--i)*#{$l1}));\r\n}<\/code><\/pre>\n
Now we’re getting somewhere!<\/p>\n
See the Pen<\/a> by thebabydino (@thebabydino<\/a>) on CodePen<\/a>.<\/p>\n
$ico-d: .5*$bar-h;\r\n$ico-f: .125;\r\n$ico-w: $ico-f*$ico-d;<\/code><\/pre>\n
We absolutely position both pseudo-elements in the middle of the button taking their dimensions into account. We then make them
inherit<\/code> their parent’s
transition<\/code>. We give the
:before<\/code> a
background<\/code>, as this will be the handle of our magnifier, make the
:after<\/code> round with
border-radius<\/code> and give it an inset
box-shadow<\/code>.<\/p>\n
[for='search-btn'] {\r\n \/* same as before *\/\r\n\t\r\n &:before, &:after {\r\n position: absolute;\r\n top: 50%; left: 50%;\r\n margin: -.5*$ico-d;\r\n width: $ico-d;\r\n height: $ico-d;\r\n transition: inherit;\r\n content: ''\r\n }\r\n\t\r\n &:before {\r\n margin-top: -.4*$ico-w;\r\n height: $ico-w;\r\n background: currentColor\r\n }\r\n \r\n &:after {\r\n border-radius: 50%;\r\n box-shadow: 0 0 0 $ico-w currentColor\r\n } \r\n}<\/code><\/pre>\n
We can now see the magnifier components on the button:<\/p>\n
See the Pen<\/a> by thebabydino (@thebabydino<\/a>) on CodePen<\/a>.<\/p>\n
[for='search-btn'] {\r\n \/* same as before *\/\r\n\t\r\n &:before {\r\n \/* same as before *\/\r\n height: $ico-w;\r\n transform: \r\n \/* collapsed: not checked, --i is 0, --j is 1\r\n * translation amount is 1*.25*$d = .25*$d\r\n * expanded: checked, --i is 1, --j is 0\r\n * translation amount is 0*.25*$d = 0 *\/\r\n translate(calc(var(--j)*#{.25*$ico-d})) \r\n \/* collapsed: not checked, --i is 0, --j is 1\r\n * scaling factor is 1 - 1*.5 = 1 - .5 = .5\r\n * expanded: checked, --i is 1, --j is 0\r\n * scaling factor is 1 - 0*.5 = 1 - 0 = 1 *\/\r\n scalex(calc(1 - var(--j)*.5))\r\n }\r\n \r\n &:after {\r\n \/* same as before *\/\r\n transform: translate(calc(var(--j)*#{-.25*$ico-d}))\r\n } \r\n}<\/code><\/pre>\n
We now have thew magnifier icon in the collapsed state:<\/p>\n
See the Pen<\/a> by thebabydino (@thebabydino<\/a>) on CodePen<\/a>.<\/p>\n
[for='search-btn'] {\r\n \/* same as before *\/\r\n transform: translate(calc(var(--j)*#{-$x})) rotate(45deg);\r\n}<\/code><\/pre>\n
Now we have the look we want for the collapsed state:<\/p>\n
See the Pen<\/a> by thebabydino (@thebabydino<\/a>) on CodePen<\/a>.<\/p>\n
$ico-d: .5*$bar-h;\r\n$ico-f: .125;\r\n$ico-w: $ico-f*$ico-d;\r\n\r\n[for='search-btn'] {\r\n \/* same as before *\/\r\n\t\r\n &:after{\r\n \/* same as before *\/\r\n \/* collapsed: not checked, --i is 0, --j is 1\r\n * border-radius is 1*50% = 50%\r\n * expanded: checked, --i is 1, --j is 0\r\n * border-radius is 0*50% = 0 *\/\r\n border-radius: calc(var(--j)*50%);\r\n transform: \r\n translate(calc(var(--j)*#{-.25*$ico-d})) \r\n \/* collapsed: not checked, --i is 0, --j is 1\r\n * scaling factor is 1 + 0*$ico-f = 1\r\n * expanded: checked, --i is 1, --j is 0\r\n * scaling factor is 0 + 1*$ico-f = $ico-f *\/\r\n scalex(calc(1 - var(--j)*.5))\r\n }\r\n}<\/code><\/pre>\n
See the Pen<\/a> by thebabydino (@thebabydino<\/a>) on CodePen<\/a>.<\/p>\n
$ico-d: .5*$bar-h;\r\n$ico-f: .125;\r\n$ico-w: $ico-f*$ico-d;\r\n\r\n[for='search-btn'] {\r\n \/* same as before *\/\r\n --hsl: 0, 0%, 0%;\r\n color: HSL(var(--hsl));\r\n\t\r\n &:after{\r\n \/* same as before *\/\r\n box-shadow: \r\n inset 0 0 0 $ico-w currentcolor, \r\n \/* collapsed: not checked, --i is 0, --j is 1\r\n * spread radius is 0*.5*$ico-d = 0\r\n * alpha is 0\r\n * expanded: checked, --i is 1, --j is 0\r\n * spread radius is 1*.5*$ico-d = .5*$ico-d\r\n * alpha is 1 *\/\r\n inset 0 0 0 calc(var(--i)*#{.5*$ico-d}) HSLA(var(--hsl), var(--i))\r\n }\r\n}<\/code><\/pre>\n
This gives us our final result!<\/p>\n
See the Pen<\/a> by thebabydino (@thebabydino<\/a>) on CodePen<\/a>.<\/p>\n
A few more quick examples<\/h3>\n
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.<\/p>\n
Responsive banners<\/h4>\n
Screenshot collage (live demo<\/a>, not fully functional in Edge due to using a calc()<\/code> value for
font-size<\/code>).<\/figcaption><\/figure>\n
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<\/code> and
:after<\/code> pseudo-elements, respectively.<\/p>\n
The backgrounds of the number squares are individual and set using a stop list variable
--slist<\/code> that’s different for each item.<\/p>\n
<p style='--slist: #51a9ad, #438c92'><!-- 1st paragraph text --><\/p>\r\n<p style='--slist: #ebb134, #c2912a'><!-- 2nd paragraph text --><\/p>\r\n<p style='--slist: #db4453, #a8343f'><!-- 3rd paragraph text --><\/p>\r\n<p style='--slist: #7eb138, #6d982d'><!-- 4th paragraph text --><\/p><\/code><\/pre>\n
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:<\/p>\n
html {\r\n --narr: 0;\r\n --comp: calc(1 - var(--narr));\r\n --wide: 1;\r\n\t\r\n @media (max-width: 36em) { --wide: 0 }\r\n\t\r\n @media (max-width: 20em) { --narr: 1 }\r\n}\r\n\r\np {\r\n --parity: 0;\r\n \r\n &:nth-child(2n) { --parity: 1 }\r\n}<\/code><\/pre>\n
The number squares are absolutely positioned and their placement depends on parity. If the
--parity<\/code> switch is off (
0<\/code>), then they’re on the left. If it’s on (
1<\/code>), then they’re on the right.<\/p>\n
A value of
left: 0%<\/code> aligns with the left edge of the number square along the left edge of its parent, while a value of
left: 100%<\/code> aligns its left edge along the parent’s right edge.<\/p>\n
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%<\/code> value. (Remember that
%<\/code> values in the case of offsets are relative to the parent’s dimensions.)<\/p>\n
left: calc(var(--parity)*(100% - #{$num-d}))<\/code><\/pre>\n
…where
$num-d<\/code> is the size of the numbering square.<\/p>\n
In the wide screen case, we also push the numbering outwards by
1em<\/code> — this means subtracting
1em<\/code> from the offset we have so far for odd items (having the
--parity<\/code> switch off) and adding
1em<\/code> to the offset we have so far for even items (having the
--parity<\/code> switch on).<\/p>\n
Now the question here is… how do we switch the sign? The simplest way to do it is by using the powers of
-1<\/code>. Sadly, we don’t have a power function (or a power operator) in CSS, even though it would be immensely useful in this case:<\/p>\n
\/*\r\n * for --parity: 0, we have pow(-1, 0) = +1\r\n * for --parity: 1, we have pow(-1, 1) = -1\r\n *\/\r\npow(-1, var(--parity))<\/code><\/pre>\n
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!<\/p>\n
\/*\r\n * for --parity: 0, we have 1 - 2*0 = 1 - 0 = +1\r\n * for --parity: 1, we have 1 - 2*1 = 1 - 2 = -1\r\n *\/\r\n--sign: calc(1 - 2*var(--parity))<\/code><\/pre>\n
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<\/code>) or not (
--wide: 0<\/code>), becomes:<\/p>\n
left: calc(var(--parity)*(100% - #{$num-d}) - var(--wide)*var(--sign)*1em)<\/code><\/pre>\n
We also control the
width<\/code> of the paragraphs with these variables and
max-width<\/code> as we want it to have an upper limit and only fully cover its parent horizontally in the narrow case (
--narr: 1<\/code>):<\/p>\n
width: calc(var(--comp)*80% + var(--narr)*100%);\r\nmax-width: 35em;<\/code><\/pre>\n
The
font-size<\/code> also depends on whether we’re in the narrow case (
--narr: 1<\/code>) or not (
--narr: 0<\/code>):<\/p>\n
calc(.5rem + var(--comp)*.5rem + var(--narr)*2vw)<\/code><\/pre>\n
…and so do the horizontal offsets for the
:after<\/code> pseudo-element (the bigger rectangle in the back) as they’re
0<\/code> in the narrow case (
--narr: 1<\/code>) and a non-zero offset
$off-x<\/code> otherwise (
--narr: 0<\/code>):<\/p>\n
right: calc(var(--comp)*#{$off-x}); \r\nleft: calc(var(--comp)*#{$off-x});<\/code><\/pre>\n
Hover and focus effects<\/h4>\n
Effect recording (live demo<\/a>, not fully functional in Edge due to nested calc()<\/code> bug).<\/figcaption><\/figure>\n
This effect is created with a link element and its two pseudo-elements sliding diagonally on the
:hover<\/code> and
:focus<\/code> states. The link’s dimensions are fixed and so are those of its pseudo-elements, set to the diagonal of their parent
$btn-d<\/code> (computed as the hypotenuse in the right triangle formed by a width and a height) horizontally and the parent’s
height<\/code> vertically.<\/p>\n
The
:before<\/code> is positioned such that its bottom left corner coincides to that of its parent, while the
:after<\/code> is positioned such that its top right corner coincides with that of its parent. Since both should have the same
height<\/code> as their parent, the vertical placement is resolved by setting
top: 0<\/code> and
bottom: 0<\/code>. The horizontal placement is handled in the exact same way as in the previous example, using
--i<\/code> as the switch variable that changes value between the two pseudo-elements and
--j<\/code>, its complementary (
calc(1 - var(--i))<\/code>):<\/p>\n
left: calc(var(--j)*(100% - #{$btn-d}))<\/code><\/pre>\n
We set the
transform-origin<\/code> of the
:before<\/code> to its left-bottom corner (
0% 100%<\/code>) and
:after<\/code> to its right-top corner (
100% 0%<\/code>), again, with the help of the switch
--i<\/code> and its complementary
--j<\/code>:<\/p>\n
transform-origin: calc(var(--j)*100%) calc(var(--i)*100%)<\/code><\/pre>\n
We rotate both pseudo-elements to the angle between the diagonal and the horizontal
$btn-a<\/code> (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.<\/p>\n
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<\/code> and
:after<\/code>, just like in the previous example with the banners:<\/p>\n
transform: rotate($btn-a) translate(calc((1 - 2*var(--i))*100%))<\/code><\/pre>\n
In the
:hover<\/code> and
:focus<\/code> states, this translation needs to go back to
0<\/code>. This means we multiply the amount of the translation above by the complementary
--q<\/code> of the switch variable
--p<\/code> that’s
0<\/code> in the normal state and
1<\/code> in the
:hover<\/code> or
:focus<\/code> state:<\/p>\n
transform: rotate($btn-a) translate(calc(var(--q)*(1 - 2*var(--i))*100%))<\/code><\/pre>\n
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<\/code> to the value of
--p<\/code> for
:before<\/code> and to the value of
--q<\/code> for
:after<\/code>, reverse the sign of the translation, and make sure we only transition the
transform<\/code> property.<\/p>\n
Responsive infographic<\/h4>\n
Screenshot collage with the grid lines and gaps highlighted (live demo<\/a>, no Edge support due to CSS variable and calc()<\/code> bugs).<\/figcaption><\/figure>\n
In this case, we have a three-row, two-column grid for each item (
article<\/code> 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<\/code>. We also have a gap in between the columns, but only in the wide screen scenario.<\/p>\n
\/\/ formulas for the columns in the wide screen case, where\r\n\/\/ $col-a-wide is for second level heading + paragraph\r\n\/\/ $col-b-wide is for the first level heading\r\n$col-1-wide: calc(var(--q)*#{$col-a-wide} + var(--p)*#{$col-b-wide});\r\n$col-2-wide: calc(var(--q)*#{$col-b-wide} + var(--p)*#{$col-a-wide});\r\n\r\n\/\/ formulas for the general case, combining the wide and normal scenarios\r\n$row-1: calc(var(--i)*#{$row-1-wide} + var(--j)*#{$row-1-norm});\r\n$row-2: calc(var(--i)*#{$row-2-wide} + var(--j)*#{$row-2-norm});\r\n$row-3: minmax(0, auto);\r\n$col-1: calc(var(--i)*#{$col-1-wide} + var(--j)*#{$col-1-norm});\r\n$col-2: calc(var(--i)*#{$col-2-wide});\r\n\r\n$art-g: calc(var(--i)*#{$art-g-wide});\r\n\r\nhtml {\r\n --i: var(--wide, 1); \/\/ 1 in the wide screen case\r\n --j: calc(1 - var(--i));\r\n\r\n @media (max-width: $art-w-wide + 2rem) { --wide: 0 }\r\n}\r\n\r\narticle {\r\n --p: var(--parity, 0);\r\n --q: calc(1 - var(--p));\r\n --s: calc(1 - 2*var(--p));\r\n display: grid;\r\n grid-template: #{$row-1} #{$row-2} #{$row-3}\/ #{$col-1} #{$col-2};\r\n grid-gap: 0 $art-g;\r\n grid-auto-flow: column dense;\r\n\r\n &:nth-child(2n) { --parity: 1 }\r\n}<\/code><\/pre>\n
Since we’ve set
grid-auto-flow: column dense<\/code>, 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.<\/p>\n
\/\/ wide case, odd items: --i is 1, --p is 0, --q is 1\r\n\/\/ we're on column 1 + 1*1 = 2\r\n\/\/ wide case, even items: --i is 1, --p is 1, --q is 0\r\n\/\/ we're on column 1 + 1*0 = 1\r\n\/\/ narrow case: --i is 0, so var(--i)*var(--q) is 0 and we're on column 1 + 0 = 1\r\ngrid-column: calc(1 + var(--i)*var(--q));\r\n\r\n\/\/ always start from the first row\r\n\/\/ span 1 + 2*1 = 3 rows in the wide screen case (--i: 1)\r\n\/\/ span 1 + 2*0 = 1 row otherwise (--i: 0)\r\ngrid-row: 1\/ span calc(1 + 2*var(--i));<\/code><\/pre>\n
For each item, a few other properties depend on whether we’re in the wide screen scenario or not.<\/p>\n
The vertical
margin<\/code>, vertical and horizontal
padding<\/code> values,
box-shadow<\/code> offsets and blur are all bigger in the wide screen case:<\/p>\n
$art-mv: calc(var(--i)*#{$art-mv-wide} + var(--j)*#{$art-mv-norm});\r\n$art-pv: calc(var(--i)*#{$art-pv-wide} + var(--j)*#{$art-p-norm});\r\n$art-ph: calc(var(--i)*#{$art-ph-wide} + var(--j)*#{$art-p-norm});\r\n$art-sh: calc(var(--i)*#{$art-sh-wide} + var(--j)*#{$art-sh-norm});\r\n\r\narticle {\r\n \/* other styles *\/\r\n margin: $art-mv auto;\r\n padding: $art-pv $art-ph;\r\n box-shadow: $art-sh $art-sh calc(3*#{$art-sh}) rgba(#000, .5);\r\n}<\/code><\/pre>\n
We have a non-zero
border-width<\/code> and
border-radius<\/code> in the wide screen case:<\/p>\n
$art-b: calc(var(--i)*#{$art-b-wide});\r\n$art-r: calc(var(--i)*#{$art-r-wide});\r\n\r\narticle {\r\n \/* other styles *\/\r\n border: solid $art-b transparent;\r\n border-radius: $art-r;\r\n}<\/code><\/pre>\n
In the wide screen scenario, we limit the items’
width<\/code>, but let it be
100%<\/code> otherwise.<\/p>\n
$art-w: calc(var(--i)*#{$art-w-wide} + var(--j)*#{$art-w-norm});\r\n\r\narticle {\r\n \/* other styles *\/\r\n width: $art-w;\r\n}<\/code><\/pre>\n
<\/code><\/p>\n
The direction of the
padding-box<\/code> gradient also changes with the parity:<\/p>\n
background: \r\n linear-gradient(calc(var(--s)*90deg), #e6e6e6, #ececec) padding-box, \r\n linear-gradient(to right bottom, #fff, #c8c8c8) border-box;<\/code><\/pre>\n
In a similar manner,
margin<\/code>,
border-width<\/code>,
padding<\/code>,
width<\/code>,
border-radius<\/code>,
background<\/code> gradient direction,
font-size<\/code> or
line-height<\/code> 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<\/code> or
background<\/code> gradient direction, also on the parity).<\/p>\n","protected":false},"excerpt":{"rendered":"
This is the first post of a two-part series that looks into the way CSS variables can be used to […]<\/p>\n","protected":false},"author":225572,"featured_media":279640,"comment_status":"open","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"_bbp_topic_count":0,"_bbp_reply_count":0,"_bbp_total_topic_count":0,"_bbp_total_reply_count":0,"_bbp_voice_count":0,"_bbp_anonymous_reply_count":0,"_bbp_topic_count_hidden":0,"_bbp_reply_count_hidden":0,"_bbp_forum_subforum_count":0,"sig_custom_text":"","sig_image_type":"featured-image","sig_custom_image":0,"sig_is_disabled":false,"inline_featured_image":false,"c2c_always_allow_admin_comments":false,"_jetpack_memberships_contains_paid_content":false,"footnotes":"","jetpack_publicize_message":"Ana shows off the power of CSS custom properties with examples of how changing one variable can control complex layouts.","jetpack_publicize_feature_enabled":true,"jetpack_social_post_already_shared":true,"jetpack_social_options":{"image_generator_settings":{"template":"highway","enabled":false}},"_share_on_mastodon":"0","_share_on_mastodon_status":"%title% %permalink%"},"categories":[4],"tags":[839,1036,688],"jetpack_publicize_connections":[],"acf":[],"share_on_mastodon":{"url":"","error":""},"jetpack_sharing_enabled":true,"jetpack_featured_media_url":"https:\/\/i0.wp.com\/css-tricks.com\/wp-content\/uploads\/2018\/11\/Untitled_Artwork-2.png?fit=4000%2C2000&ssl=1","jetpack-related-posts":[],"_links":{"self":[{"href":"https:\/\/css-tricks.com\/wp-json\/wp\/v2\/posts\/274807"}],"collection":[{"href":"https:\/\/css-tricks.com\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/css-tricks.com\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/css-tricks.com\/wp-json\/wp\/v2\/users\/225572"}],"replies":[{"embeddable":true,"href":"https:\/\/css-tricks.com\/wp-json\/wp\/v2\/comments?post=274807"}],"version-history":[{"count":153,"href":"https:\/\/css-tricks.com\/wp-json\/wp\/v2\/posts\/274807\/revisions"}],"predecessor-version":[{"id":279949,"href":"https:\/\/css-tricks.com\/wp-json\/wp\/v2\/posts\/274807\/revisions\/279949"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/css-tricks.com\/wp-json\/wp\/v2\/media\/279640"}],"wp:attachment":[{"href":"https:\/\/css-tricks.com\/wp-json\/wp\/v2\/media?parent=274807"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/css-tricks.com\/wp-json\/wp\/v2\/categories?post=274807"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/css-tricks.com\/wp-json\/wp\/v2\/tags?post=274807"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}