Container queries via custom properties (aka CSS variables) #5

Open
ausi opened this Issue Dec 13, 2016 · 8 comments

Comments

Projects
None yet
4 participants

ausi commented Dec 13, 2016

I’ve got a new idea for a solution to container queries 🤓

What we already have today

To describe the concept, lets first take a look at what we already have in browsers, imagine the following setup:

:root {
    --context-width: 100vw;
}
.one-third {
    width: 33.3%;
    --context-width: calc(var(--context-width) / 3);
}
.two-thirds {
    width: 66.6%;
    --context-width: calc(var(--context-width) / 3 * 2);
}

Now I can create a component that is in a 16:9 ratio to its context width as easy as:

.component {
    height: calc(var(--context-width) / 16 * 9);
}

No matter how nested the one-third and two-thirds containers are, a component inside them or at the root level would always be in the desired 16:9 ratio. This works already, although we need to hack a little because it’s not allowed to reference a CSS variable itself while setting it, take a look at this CodePen.

New concept for CSS conditions

If we take this idea further and introduce a concept for custom property conditions with this syntax:

if( <condition> , <value-if-true>, <value-if-false> )

we could do something like:

.component {
    float: if(var(--context-width) > 200px, left, none);
}

This way we told the component to only float left if it’s in a context (or container?) with more than 200px width, otherwise it should not float. Sounds pretty much like container queries to me 🎉

From the perspective of a browser

My knowledge for browser internals in very limited, but AFAIK one big problem with an implementation of container queries is, that the engine would have to jump between layout and style computation, which could result in unstable results and might be very bad for performance. I think the concept of simple CSS conditions would not suffer from that problem. Taking the example from above, the condition var(--context-width) > 200px can be worked out without the need for a layout. First the var() part would be resolved to something like calc((100vw / 3) / 3 * 2) and further to 22.22vw. This value can then be easily compared to 200px.

Flexibility

Another benefit I see with this solution is, that the CSS author can decide how to set and inherit the variables. If one creates a website which has bright and dark sections, he could set --context-dark to true or false and create components that just look perfect in every place. Such binary conditions already work in browsers today if you are very creative.

Downsides

The main downside of this solution is that we need to set variables for every container that changes the available width for its children. And we would sometimes not get the “exact” value if e.g. a scrollbar reduces the available width.

What do you think?

The problem I'm seeing with the first example is that they're based on viewport-percentage units, but the elements you'll need to be styling may not scale perfectly with the browser's width and/or height.

What we need to do these kinds of calculations is a unit that is aware of the width and height of the element as it appears on the page (not as we want it to appear). With that information we could write the kind of code in the first examples. Consider the difference in syntax between the two following examples - the first uses JavaScript to check the offsetWidth which is wild, and the second uses ew units to represent 1% of an element's width which is a much nicer abstraction for writing CSS:

<div class=demo-1></div>
<style>
  @element '.demo-1' {
    $this {
      background: lime;
      height: eval('offsetWidth / (16/9)')
    }
  }
</style>
<script src=http://elementqueries.com/EQCSS.js></script>
<div class=demo-2></div>
<style>
  @element '.demo-2' {
    $this {
      background: orange;
      height: calc(100ew / (16/9));
    }
  }
</style>
<script src=http://elementqueries.com/EQCSS.js></script>

I think before a solution like this would be usable, we would need ew, eh, emin, and emax units so the element-based queries could be based on element-based conditions, not triggering element-based queries with browser-based conditions.

As for the conditional if() statements in CSS - I've experimented with this a lot and I think it works well for prototyping, and abstracting away JavaScript - but it's very limiting in that in many situations beyond building a prototype or demo where you would actually want to use this - you'd often find yourself using this sort of if() statement over and over in your CSS rules, leading to a lot of duplicate code that's hard to read or maintain.

Here's a version of your if() example that works in EQCSS syntax, and then some examples of hairier if statements I've written in EQCSS as well so you could see how people would try to use it and where usefulness lies in a feature like that:

<img src=http://staticresource.com/user.png class=component>
<style>
  @element 'html' {
    img {
      float: eval('innerWidth > 200 ? "left" : "right"')
    }
  }
</style>
<script src=http://elementqueries.com/EQCSS.js></script>

And then here are other ways I've used if() statements inside CSS. I think it's incredibly powerful, but very ugly, and the things we use this to try to solve would be better exposed as nicer syntax in CSS:

Min/Max font-size

@element '[data-min-font],[data-max-font]' {
  $this {
    font-size: eval('
      var vw = innerWidth/100*10, /* equal to 10vw */
          min = getAttribute("data-min-font"),
          max = getAttribute("data-max-font");
      if (min !== null && max !== null) {
        vw <= min ? min : max <= vw ? max : vw
      } else if (min !== null) {
        vw <= min ? min : vw;
      } else if (max !== null) {
        max <= vw ? max : vw;
      }
    ')px;
  }
}

Sniffing Element Orientation

@element 'div' {
  $this {
    background: eval("
      offsetWidth == offsetHeight ? 'blue'
      : offsetWidth > offsetHeight ? 'green'
      : offsetWidth < offsetHeight ? 'red'
      : null
    ")
  }
}

Faking :in-viewport

@element 'p' {
  $this {
    /* Color is red when partly in-viewport */
    color: eval("
      var top = offsetTop - innerHeight;
      var bottom = top + offsetHeight;
      if (
        (scrollY < offsetTop + offsetHeight)
        && (top < scrollY)
        && (bottom < scrollY + offsetHeight)
      ){
        'tomato'
      } else {
        'inherit'
      }
    ");
    /* Background is green when fully in-viewport */
    background: eval("
      var top = offsetTop - innerHeight;
      var bottom = top + offsetHeight;
      if (
        (scrollY < offsetTop)
        && (top < scrollY)
        && (bottom < scrollY)
      ){
        'darkgreen'
      } else {
        'inherit'
      }
    ");
  }
}

Centered or Overflow

This example below uses if() statements for the selectors, not even a property or rule:

@element 'div h2' {
  eval('offsetHeight <= parentNode.offsetHeight ? "$this" : ""') {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
  }
  eval('offsetHeight >= parentNode.offsetHeight ? "$parent" : ""') {
    overflow: auto;
    overflow-y: scroll;
    border-color: lime;
  }
}

Using If statement as selector

In this example we use JavaScript to check the background-image of an element and only write a selector (applying the rule) for those that return true.

<div style=background-image:url(http://staticresource.com/user.png)></div>
<div style=background-image:url(http://staticresource.com/nebula.jpg)></div>
<style>
  div {
    height: 200px;
  }
  @element 'div' {
    eval("window.getComputedStyle($it).backgroundImage.indexOf('nebula') !== -1 ? '$this' : '' ") {
      border: 10px solid lime;
    }
  }
</style>
<script src=http://elementqueries.com/EQCSS.js></script>

Based on these experiments and more I think that having container-style queries so we can write conditions once and affect styles for many elements is better than writing conditions many times in many rules.

Another takeaway is that having an if() kind of functionality would be amazing in CSS, but being too liberal with it, it's crazy and could lead to some really bad code. Too conservative with it and you'll miss the parts that make it useful and powerful.

Should the if() work as a value, as a selector, as a responsive condition for elements?

ausi commented Dec 18, 2016

The problem I'm seeing with the first example is that they're based on viewport-percentage units, but the elements you'll need to be styling may not scale perfectly with the browser's width and/or height.

I used the viewport units as an example, the CSS author could set the CSS variable to whatever suits best. An element with a fixed width like 200px can set the variable to the same value --context-width: 200px;.

What we need to do these kinds of calculations is a unit that is aware of the width and height of the element as it appears on the page (not as we want it to appear).

IMO that’s exactly the thing that is hard/impossible for browsers to implement, because it has to jump between layout and style computation.

For setting aspect-ratios we may get a new property in the future, this is not a topic for container queries IMO.

Here's a version of your if() example that works in EQCSS syntax, and then some examples of hairier if statements I've written in EQCSS as well

Your eval-based if() examples are different to what I want to propose here. They are all using values that rely on the layout process of the browser like offsetWidth or offsetTop.

Based on these experiments and more I think that having container-style queries so we can write conditions once and affect styles for many elements is better than writing conditions many times in many rules.
Should the if() work as a value, as a selector, as a responsive condition for elements?

Using if() only as the value for a property is the simplest way to start with IMO. If browser makers agree that this is a solution that can be implemented, we could think of more powerful versions like:

.component {
    if(var(--context-width) > 200px) {
        float: left;
        background: red;
    }
    else {
        float: none;
        background: green;
    }
}

or:

.component:if(var(--context-width) > 200px) {
    float: left;
    background: red;
}
.component:if(var(--context-width) <= 200px) {
    float: none;
    background: green;
}

ausi commented Mar 6, 2017

I proposed the CSS conditions feature on WICG Discourse:
https://discourse.wicg.io/t/css-conditions-with-variables/2048

And wrote an article on how they might be an alternative for container queries:
https://au.si/css-conditions-cq-alternative

Hey @ausi, great idea putting it on WICG Discourse to gather comment, it'll be great to see what people have to say!

In your article you refer to the circularity problem with element/container queries, and today I recorded a video about this very issue: https://www.youtube.com/watch?v=QfM_JwSDdjo

Even though this is not as convenient as the original proposals for container queries, it should be much easier to implement, doesn’t suffer from the circularity problem and is still powerful enough to solve the RICG use-cases.

I think there's a way to handle circularity that makes sense to how CSS works already!

ausi commented Mar 6, 2017

I think there's a way to handle circularity that makes sense to how CSS works already!

I don’t think that’s true for container queries. I watched your video, but I don’t think :hover has the same circularity issue. As you mentioned, :hover only gets triggered when the mouse is moving, but container queries would get triggered every time the page gets reflowed/repainted. Triggering the container queries could then cause another reflow and, in the worst case, would end in an infinite loop. You can take a look at #3 (comment) and https://discourse.wicg.io/t/a-pseudo-class-for-when-an-element-is-stickily-spec-term-positioned/947/12 for information about the problems with implementing container queries.

beep commented Mar 7, 2017

@ausi: I just gave https://au.si/css-conditions-cq-alternative a quick read, and found it really interesting. I’m ~offline this week, but give a quick 🙌; I’ll try to have more substantive comments in next week.

ausi commented Mar 7, 2017

@beep: Awesome thanks! I’m looking forward to it :)

I had some thoughts about this today, and was thinking about how this could allow to choose among a set of differing layouts easily without need to extend this further, and wrote this: https://jsfiddle.net/35758bg7/1/ (basically you can duplicate all your layout paths and then from there choose one based on settings a small subset of properties on it based on --width)

Posted this here just not to loose it, I don't say this is very smart or relevant, just didn't know where to post it otherwise :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment