DEV Community

Mauro Bartolomeoli
Mauro Bartolomeoli

Posted on

More CSS reactions

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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)
  })
})
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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

With the PostCSS plugin you can do this:

@for $i from 0 to 360 {
    :root:has(#hue:[value="$i"]) {
        --hue: $i;
    }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
@supports selector(*:has(*)) {
    li a[href*='-polyfill'] {
        display: none;
    }
}

@supports not selector(*:has(*)) {
    li a:not([href*='polyfill']) {
        display: none;
    }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)