This is a follow up to my previous article on Simple reactions in HTML + CSS.
In that post I explained my findings on what can be done to implement interactive behaviours using HTML and CSS only.
Recently I did some more work on this topic and I am going to what I learned. A short list:
- Getting rid of CSS variables for the most simple cases
- Handling reactions with numeric state
- Creating fallbacks for browsers not supporting some of the needed stuff
Toggle with no CSS variables
In the toggle example, we used the :has pseudo-selector to lift a checkbox state to a CSS variable, and then used the variable to apply a panel open/closed state.
Splitting the code into a lifting rule and an apply rule is useful to make the state more explicit, via a CSS variable. If the state is applied only once, the same result can be obtained with the following CSS:
#panel {
position: absolute;
top: 0;
left: -300px;
width: var(--panel-width);
bottom: 0;
border: solid 1px black;
}
:root:has(#toggle:checked) #panel {
left: 0;
}
In this case we have collapsed lifting and apply in a single rule with two nested selectors:
- :root:has(#toggle:checked) lifts toggle state
- #panel applies it
Handling reactions with numeric state
So far we have reasoned about reactions with a very limited number of possible states (two in the case of the toggle, less than 10 for the tab panel).
What if we want to work with a more extended set of states? Is that even possible?
There is space for new challenges, indeed!
Third challenge: HSL color chooser
We want to build a color using the HSL (Hue-Saturation-Lightness) encoding, using 3 different slider inputs for H S and L.
We also want to show a sample of the chosen color.
This is a suitable HTML for the purpose.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CSS Experiments: Color Chooser</title>
</head>
<body>
<div id="root">
<div id="bars">
<div class="bar"><label>H</label><input type="range" id="hue" min="0" max="360" value="360"/></div>
<div class="bar"><label>S</label><input type="range" id="saturation" min="0" max="100" value="100" /></div>
<div class="bar"><label>L</label><input type="range" id="lightness" min="0" max="100" value="50" /></div>
</div>
<div id="sample"></div>
</div>
</body>
</html>
We use range type input controls for sliders, and a simple div for the sample.
Some basic CSS:
#bars {
display: flex;
flex-direction: column;
flex-basis: 30%;
}
#sample {
width: 200px;
aspect-ratio: 1;
background-color: hsl(var(--hue), var(--saturation), var(--lightness));
}
Here we suppose to show the sample using the hsl CSS function to aggregate 3 different values for hue, saturation and lightness, stored in CSS variables.
So, what we need to do now is to find a way to store the slider current values into the CSS variables above.
First obstacle: how do we extract the input value into a CSS variable? Theoretically we have attribute selectors, so we may think to do something like:
:root:has(#hue[value="100"]) {
--hue: 100;
}
Now, repeat for every possible hue value (from 0 to 360) and tadah! we did it... well not exactly. The fact is, the attribute value you can use in CSS is not the actual (user entered) value, because a DOM element value property and the same DOM element value attribute are not the same thing, and the property is not automatically synchronized to the attribute, so even if the user moves the slider, the attribute and the related CSS variable will not be updated (I forgot to say that CSS has no way to use the value property, only the attribute, and that creates a lot of confusion, don't you agree?).
Unfortunately, there is no way to mirror the property value to the attribute without using Javascript, for example with this snippet of code:
document.querySelectorAll("input[type='range']").forEach(function(input) {
input.addEventListener("input", function(evt) {
input.setAttribute("value", evt.target.value)
})
})
Here is a complete example using these techniques:
It would be wonderful if a rule to select the dynamic value would exist in CSS, but I could not find any, so I thought of one, and implemented a postcss plugin (postcss-dynamic-value) and polyfill to implement it here.
Using the plugin, the following CSS will do the job:
:root:has(#hue:[value="100"]) {
--hue: 100;
}
As you can see it's just the standard attribute selector syntax ([value=]) with a prefixed colon. I chose this syntax to match the pseudo-selector syntax of the state selectors we used so far (e.g. :checked for checkboxes).
Second obstacle: I know, I know, you are very happy to manually write 360 rules for all the possible hue values, and repeat the same (100 times) for saturation and lightness.
The only solutions I found for this are
- a proposal on matching attribute values with variables
- a PostCSS plugin to implement loops
With the PostCSS plugin you can do this:
@for $i from 0 to 360 {
:root:has(#hue:[value="$i"]) {
--hue: $i;
}
}
Fallbacks
In a perfect world, we would like our reactions to work flawlessly on every browser, but as I already mentioned several times, my experiments are testing the bleeding edge of CSS improvements, so that's not the case.
What we can do is introducing fallbacks whenever possible. I will show here some techniques for doing that on our experiments.
Showing different stuff when a feature is supported or not
The CSS @supports rule is a perfect match for applying different styles when a feature is supported or not.
Let's see for example how we can show links to different pages for browsers not supporting the :has pseudo-selector:
<li><a href="toggle.html">Toggle</a></li>
<li><a href="toggle-polyfill.html">Toggle</a></li>
@supports selector(*:has(*)) {
li a[href*='-polyfill'] {
display: none;
}
}
@supports not selector(*:has(*)) {
li a:not([href*='polyfill']) {
display: none;
}
}
Preprocessing and polyfills
We have already introduced the PostCSS preprocessor and its plugins to use not-yet-implemented features, such as the @when and @for selectors.
Using a preprocessor is my preferred fallback option, but sometimes this is not sufficient, and a polyfill is needed instead.
A polyfill is Javascript code that implements a (future) CSS feature that is not achievable by CSS alone.
To implement :has support, the css-has-pseudo polyfill can be used.
This polyfill includes both a PostCSS plugin and a JS function to be used in the HTML page.
<script src="https://unpkg.com/css-has-pseudo@4.0.2/dist/browser-global.js"></script>
<script>
if (!CSS.supports("selector(*:has(*))")) {
cssHasPseudo(document)
}
</script>
CSS.supports is the Javascript counterpart of the CSS @supports rule.
Links
All the experiments mentioned in this and the previous article are available in source code here and published here.
Top comments (0)