Logical Operations with CSS Variables
Get affordable and hassle-free WordPress hosting plans with Cloudways —start your free trial today.
Very often, while using switch variables (a variable that’s either0
or1
, a concept that’s explained in a greater detail inin this post), I wish I could perform logical operations on them. We don’t have functions likenot(var(--i))
orand(var(--i), var(--k))
in CSS, but we can emulate these and more with arithmetic operations in acalc()
function.
This article is going to show you whatcalc()
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
) from1
:
--notj: calc(1 - var(--j))
If--j
is0
, then--notj
is1
(1 - 0
). Ifj
is1
, then--notj
is0
(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.
Theand
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 either0
or1
, 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 theand
operation is1
if both our switch variables are1
and0
otherwise. Looking at it the other way, this result is0
if at least one of the two switch variables is0
.
Now you need to think of it this way: the result of what arithmetic operation is0
if at least one of the two operands is0
? That’s multiplication, as multiplying anything by0
gives us0
!
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
is0
(0*0
) - for
--k: 0
,--i: 1
, we have that--and
is0
(0*1
) - for
--k: 1
,--i: 0
, we have that--and
is0
(1*0
) - for
--k: 1
,--i: 1
, we have that--and
is1
(1*1
)
nand
Sincenand
isnot and
, we need to replace the--j
in thenot
formula with the formula forand
:
--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
is1
(1 - 0*0 = 1 - 0
) - for
--k: 0
,--i: 1
, we have that--nand
is1
(1 - 0*1 = 1 - 0
) - for
--k: 1
,--i: 0
, we have that--nand
is1
(1 - 1*0 = 1 - 0
) - for
--k: 1
,--i: 1
, we have that--nand
is0
(1 - 1*1 = 1 - 1
)
or
The result of theor
operation is1
if at least one of our switch variables is1
and0
otherwise (if both of them are0
).
The first instinct here is to go for addition, but while that gives us0
if both--k
and--i
are0
and1
if one is0
and the other one is1
, it gives us2
if both of them are1
. So that doesn’t really work.
But we can use the good oldDe Morgan’s laws, one of which states:
not (A or B) = (not A) and (not B)
This means the result of theor
operation is the negation of theand
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
is0
(1 - (1 - 0)*(1 - 0) = 1 - 1*1 = 1 - 1
) - for
--k: 0
,--i: 1
, we have that--or
is1
(1 - (1 - 0)*(1 - 1) = 1 - 1*0 = 1 - 0
) - for
--k: 1
,--i: 0
, we have that--or
is1
(1 - (1 - 1)*(1 - 0) = 1 - 0*1 = 1 - 0
) - for
--k: 1
,--i: 1
, we have that--or
is1
(1 - (1 - 1)*(1 - 1) = 1 - 0*0 = 1 - 0
)
nor
Sincenor
isnot 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
is1
((1 - 0)*(1 - 0) = 1*1
) - for
--k: 0
,--i: 1
, we have that--nor
is0
((1 - 0)*(1 - 1) = 1*0
) - for
--k: 1
,--i: 0
, we have that--nor
is0
((1 - 1)*(1 - 0) = 0*1
) - for
--k: 1
,--i: 1
, we have that--nor
is0
((1 - 1)*(1 - 1) = 0*0
)
xor
The result of thexor
operation is1
when one of the two operands is1
and the other one is0
. This feels trickier at first, but, if we think this means the two operands need to be different for the result to be1
(otherwise it’s0
), we stumble upon the right arithmetic operation to use insidecalc()
: subtraction!
If--k
and--i
are equal, then subtracting--i
from--k
gives us0
. 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 is1
.
Close, but not quite! We get the result we want in three out of four scenarios, but we need to get1
, not-1
in the--k: 0
,--i: 1
scenario.
However, one thing that-1
,0
and1
have in common is that multiplying them with themselves gives us their absolute value (which is1
for both-1
and1
). 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
is0
((0 - 0)*(0 - 0) = 0*0
) - for
--k: 0
,--i: 1
, we have that--xor
is1
((0 - 1)*(0 - 1) = -1*-1
) - for
--k: 1
,--i: 0
, we have that--xor
is1
((1 - 0)*(1 - 0) = 1*1
) - for
--k: 1
,--i: 1
, we have that--xor
is0
((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.

So let’s see what this means in terms of CSS!
First off, on the<body>
, we use a switch that goes from0
in the narrow screen case to1
in the wide screen case. We also change theflex-direction
this way (if you want a more detailed explanation of how this works, check out mysecond 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 is0
if the checkbox is unchecked and1
if the checkbox is:checked
. With the help of this switch, we give our advanced controls panel a disabled look (via afilter
chain) and we also disable it (viapointer-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
is1
), regardless of whether the checkbox is:checked
or not,or if the checkbox is:checked
(so if--i
is1
), regardless of whether we’re in the wide screen case or not.
This is precisely theor
operation!
So we compute an--or
variable:
.advanced { /* same as before */ --or: calc(1 - (1 - var(--k))*(1 - var(--i)));}
If this--or
variable is0
, this means we’re in the narrow screen caseand our checkbox is unchecked, so we want to zero theheight
of the advanced controls panel and also its verticalmargin
:
.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 ofCSS-ing theJohnson 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.
This shape is made up out of apentagonal rotunda without the big decagonal base and adecagonal antiprism without its top decagon. The interactive demo below shows how these two components can be built by folding theirnets of faces into 3D and then joined to give us the shape we want.
See thePen by thebabydino (@thebabydino) onCodePen.
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 is0
for the faces that are a part of the antiprism and1
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!
.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 is0
if they have a vertex pointing up (such faces have a.dir
class) and1
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 has10
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 has10
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 has10
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 has5
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 they axis and change the shape’s tilt.
See thePen by thebabydino (@thebabydino) onCodePen.
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 they axis which can be started/ paused at any moment and to the manual rotations around thex axis which can be controlled via the sliders.
See thePen by thebabydino (@thebabydino) onCodePen.
In order to make things easier for ourselves, we set thetransform-origin
of the.lat
faces on the middle of their base edges (bottom horizontal edges).
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 thetransform-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 thePen by thebabydino (@thebabydino) onCodePen.
We place our lateral faces where we want them to be in four steps:
- We rotate them around theiry axis such that their base edges are now parallel to their final positions. (This also rotates their local system of coordinates — thez axis of an element always points in the direction that element faces.)
- We translate them such that their base edges coincide with their final positions (along the edges of the base faces of the two components).
- If they need to have a vertex pointing down, we rotate them around theirz axis by half a turn.
- We rotate them around theirx 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 they axis rotation and the slider for thex axis rotation).
See thePen by thebabydino (@thebabydino) onCodePen.
They 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 basesection.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> <div> <!-- LATERAL faces --> <div></div> <!-- 9 more identical faces, so we have 10 lateral antiprism faces pointing up --> <div></div> <!-- 9 more identical faces, so we have 10 lateral antiprism faces pointing down --> <div></div> <div></div> <!-- 4 more identical pairs, so we have 10 lateral rotunda faces pointing up --> <div></div> <!-- 4 more identical faces, so we have 5 lateral rotunda faces pointing down --> <!-- BASE faces --> <div></div> <div></div> </div></section>
This means faces0... 9
are the10
lateral antiprism faces pointing up, faces10... 19
are the10
lateral antiprism faces pointing down, faces20... 29
are the10
lateral rotunda faces pointing up and faces30... 34
are the5
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 at0
for each group of faces, which means the indices for faces0... 9
,10... 19
and20... 29
go from0
through9
, while the indices for faces30... 34
go from0
through4
. Great, but if we just multiply these indices with the base angle1 of the common decagon to get they 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 they axis.
See thePen by thebabydino (@thebabydino) onCodePen.
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 thePen by thebabydino (@thebabydino) onCodePen.
The faces we want to target are the faces for which both of--i
and--k
are0
, so what we need here is multiply the result of theirnor
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
by2
, but only for these faces.
See thePen by thebabydino (@thebabydino) onCodePen.
What we’re targeting now are the faces for which both--i
and--k
are1
(so the faces for which the result of theand
operation is1
), so what we need is to multiply--idx
with1
plus theirand
:
--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 usetranslate3d()
. We don’t move any of our faces left or right, so the value along thex axis is always0
. We do move them however vertically (along they axis) and forward (along thez 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
is1
and--k
is1
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 theand
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
is1
and--k
is0
as well as the faces for which--i
is0
and--k
is1
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 thexor
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
is0
and--k
is0
get translated down (positive direction) by half the total height of the compound shape. So what we use here is thenor
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 thePen by thebabydino (@thebabydino) onCodePen.
Along thez 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
is1
and--k
is1
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 areand
andnand
!
// 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 thePen by thebabydino (@thebabydino) onCodePen.
Next, we want to make all.rev
(for which--k
is1
) 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 thez axis to the transform chain, but only for the faces for which--k
is1
:
// same as before--az: calc(var(--k)*.5turn);transform: rotatey(var(--ay)) translate3d(0, var(--y), var(--z)) rotate(var(--az));
See thePen by thebabydino (@thebabydino) onCodePen.
The pentagonal faces (for which--p
is1
) are then all rotated around thex axis by a certain angle:
--ax: calc(var(--p)*#{$ax5});
In the case of the triangular faces (for which--p
is0
, 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
is0
, so we need to multiply their corresponding angle value with--noti
here. The rotunda faces are those for which--i
is1
, and out of these, the ones pointing up are those for which--k
is0
and the ones pointing down are those for which--k
is1
.
--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 thePen by thebabydino (@thebabydino) onCodePen.
1For 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 thePen by thebabydino (@thebabydino) onCodePen.