We call a reaction something happening in response to an event. Following this definition, we can describe web development as implementing all the possibile reactions of a web page to external events (user triggered or not).
The general way to implement a reaction on a web page is to:
- bind an event handler to the page event
- implement some business logic (using Javascript) inside the event handler
- update the DOM (HTML) of the page to reflect the reaction effects
CSS may be involved if the update triggers visual changes that are applied through CSS rules (e.g. adding/removing a class from a page element)
When reactions have only visual effects, the business logic may be so trivial that I think HTML and CSS should be enough to describe it (e.g. clicking on a button to display or hide some content), but most of the time this is not possible (yet)!
Fortunately HTML and CSS standards are continuously improved by the WHATWG and W3C committees, with features that can open new scenarios for us as web developers (making something that was previously impossible, possible).
For this reason I recently did some experiments to understand what's possible and what not, using the level of standards currently implemented by the most popular browsers.
I will present the results as a list of challenges in this post and eventually some follow-ups. So, let's start with the first challenge!
First challenge: Toggle
We have a toggle reaction when an event (e.g. a click on a button) switches an element between two different states (e.g. open/closed).
First obstacle: CSS, being fully declarative, does not have an event concept, so we are not able to respond to an event with CSS. What we can do instead is:
- use HTML elements that are natural toggles as the source of the event (e.g. checkbox inputs)
- sync the event source state to our target element state using CSS rules (e.g. via the :checked pseudo-class)
There is a very interesting proposal about toggle reactions here:
The proposal is about the ability to use a page element state to drive another element state, exactly what we need for this experiment.
Unfortunately, since this is only a proposal, we cannot use it to implement our toggle in this very elegant way in any real browser.
Second obstacle: CSS, historically, did not allow sharing state between two generic elements: elements can only be styled using an ancestor state. If our event source and the toggle target were not in this relationship (and they often weren't) we hit a wall.
This changed recently, thanks to the implementation of the :has pseudo-class in a list of popular browsers.
What we can do now is:
- use CSS variables as a shared state container
- use the :has pseudo-class to lift state from the event source to a common container of the event source and target element.
Let's implement an example where:
- the event source element is a checkbox with states checked and not checked
- the target element is a panel (div) with states open and closed
Here is the HTML with the two elements:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" type="text/css" href="toggle.css">
<title>CSS Experiments: Toggle</title>
</head>
<body>
<input type="checkbox" id="toggle" />
<div id="panel"></div>
</body>
</html>
Here is the CSS to lift the toggle state to a common container (I used :root, but body would work as well), as the variable panel-closed (you may wonder why we are not using panel-open: the reason is that this will simplify our rules).
:root::not(:has(#toggle:checked)) {
--panel-closed: 1;
}
:root:has(#toggle:checked) {
--panel-closed: 0;
}
To complete our experiment, we need to drive the panel state (open/closed) using the variable we have just introduced.
Third obstacle: CSS has a lot of functions to manage numbers, but nothing to work with other kind of data, such as booleans or strings. For our purpose we would like to set a panel property such as display or visibility to hide/show the panel in reaction to the panel-closed variable value. This is simply not allowed by CSS! We need to revert to a numeric property. In our example we will use the left property, so that the panel moves out of page when closed.
And here is the CSS to get that:
:root {
--panel-width: 300px;
}
#panel {
position: absolute;
top: 0;
left: calc(var(--panel-width) * -1 * var(--panel-closed));
width: var(--panel-width);
bottom: 0;
border: solid 1px black;
}
Not so simple, so I am going to explain it. As we mentioned, a closed panel is just a panel positioned out of the page bounds (left = -300px). To get that we use the CSS calc function, so that when closed=1, left=-300px and when closed=0, left=0.
Having panel-closed instead of panel-open made this function simpler, that is always good for learning purposes.
A nice side effect is that using left, we can also animate the opening / closing behaviour. Here is a more complete version of the example, with animation included.
Disclaimer: at the time of writing this is working in Chrome, Edge and Safari. It will not work on Firefox, due to the missing support of the :has pseudo-class.
Second challenge: Tab Panel
To implement a tab panel, we can leverage the work done for the toggle experiment, observing that the main difference is that both the source (the tabs) and the target (the panels) elements have more than two states.
Let's start with the HTML, where we will use radio inputs to represent the tabs, and the usual divs for panels:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" type="text/css" href="tabs.css">
<title>CSS Experiments: Tabs</title>
</head>
<body>
<input type="radio" name="tabs" id="tab1" checked/>
<input type="radio" name="tabs" id="tab2"/>
<input type="radio" name="tabs" id="tab3"/>
<div id="tabpanel1" class="panel">Panel 1</div>
<div id="tabpanel2" class="panel">Panel 2</div>
<div id="tabpanel3" class="panel">Panel 3</div>
</body>
</html>
Our lifted state will be a number to store the currently selected tab. Here is the related CSS:
:root:has(#tab1:checked) {
--selected-tab: 1;
}
:root:has(#tab2:checked) {
--selected-tab: 2;
}
:root:has(#tab3:checked) {
--selected-tab: 3;
}
Now, to drive the panels visibility we can try to leverage left and calc again:
#tabpanel1 {
left: calc((var(--selected-tab) - 1) * 99999px);
}
#tabpanel2 {
left: calc((var(--selected-tab) - 2) * 99999px);
}
#tabpanel3 {
left: calc((var(--selected-tab) - 3) * 99999px);
}
With this "simple" formula, we put out of view the panels that are not selected.
Here is a more complete version of the example:
At this point, I was not very happy with the results of my experiment: it is working on a bunch of real browsers (waiting for Firefox to finally implement :has support), but it is also really hacky.
This is when I discovered an interesting proposal by Martin Auswöger called CSS Conditions (you can find more information here and here) that would allow the following:
#tabpanel1 {
visibility: hidden;
@when (var(--selected-tab) = 1) {
visibility: visible;
}
}
#tabpanel2 {
visibility: hidden;
@when (var(--selected-tab) = 2) {
visibility: visible;
}
}
#tabpanel3 {
visibility: hidden;
@when (var(--selected-tab) = 3) {
visibility: visible;
}
}
As you may see, this allows setting non numeric properties using variables, but as many proposals, no real browser implements it so far.
The author of the same proposal wrote another interesting article on how to implement CSS conditions today, so I tried to implement an alternative version of the experiment, using his approach.
The hack here is to use animation keyframes in a creative way to set properties when a variable changes. For our example, we will switch the visibility from hidden to visible, for the currently selected tab.
Disclaimer: not all CSS properties can be used in animation keyframes, so this technique cannot be applied to any property, for example visibility works, but display does not.
.panel {
visibility: hidden;
animation: 1s tab-is-selected paused;
}
#tabpanel1 {
animation-delay: calc((1 - var(--selected-tab)) * 1s);
}
#tabpanel2 {
animation-delay: calc((2 - var(--selected-tab)) * 1s);
}
#tabpanel3 {
animation-delay: calc((3 - var(--selected-tab)) * 1s);
}
@keyframes tab-is-selected {
from {
visibility: visible;
}
}
A short explanation of this technique:
- all panels have an associated animation that is paused to a specific frame (the initial, or from one)
- elements have a default value for the desidered property (e.g. visibility = hidden)
- the animation frame sets the desired property to a new state (e.g. visibility = visible)
- the animation-delay property is set to 0 (effectively setting the property to the animation from value) only for the selected tab.
For more details look at the original article.
The complete example is here:
But... this is still hacky!
Can we do better? Yes, we can, using PostCSS to transform @when rules to the css we have seen above.
I could not find any PostCSS plugin for this, so I created one that is really quick and dirty, but works for this simple use case. You can find it here.
Finally I collected all these experiments in a github repo.
Thanks for reading, and if you have any CSS challenge you are struggling with, keep in touch!
Top comments (0)