w3c / csswg-drafts

CSS Working Group Editor Drafts
https://drafts.csswg.org/
Other
4.42k stars 647 forks source link

[selectors][css-transitions-2] `:starting-style` pseudo-class #10356

Open brandonmcconnell opened 3 months ago

brandonmcconnell commented 3 months ago

Related specifications & authors

Introduction

I've received valuable feedback regarding the complexity of an --open switch I used to demonstrate @starting-style (shown below), which I employed to avoid redundant styles.

dialog {
  --open: 0;
  --closed: calc(1 - var(--open));
  transform: translateY(calc(var(--closed) * -50%));

  &, &::backdrop {
    --duration: calc((var(--open) * 0.5s) + (var(--closed) * 0.25s));
    transition: all var(--duration) ease-out allow-discrete;
    opacity: var(--open);
  }

  &[open] {
    --open: 1;
  }

  @starting-style {
    &[open] {
      --open: 0;
    }
  }
}

This way, I could set up my styles in one place and control the open/closed state via a single variable. However, as I added more styles to my state, I found myself having to duplicate them into @starting-style each time.

Problem Statement

While @starting-style is undeniably powerful and allows for granular control, it often requires redundancy even for simpler use cases. Consider the following example, the same as above but without the variable trick:

dialog {
  transform: translateY(-50%);
  &, &::backdrop {
    transition: all 0.25s ease-out allow-discrete;
    opacity: 0;
  }
  &[open] {
    transform: translateY(0);
    &, &::backdrop {
      transition-duration: 0.5s;
      opacity: 1;
    }
  }
  @starting-style {
    &[open] {
      transform: translateY(-50%);
      &, &::backdrop {
        opacity: 0;
      }
    }
  }
}

In this example, the closed-state styles are duplicated inside @starting-style. This duplication becomes more apparent when animating from a custom set of closed-state styles to the default open-state styles:

dialog {
  transition: all 0.25s ease-out allow-discrete;
  &:not([open]) {
    transform: translateY(-50%);
    &, &::backdrop {
      opacity: 0;
    }
  }
  @starting-style {
    &[open] {
      transform: translateY(-50%);
      &, &::backdrop {
        opacity: 0;
      }
    }
  }
}

The styles inside @starting-style are often the same as the closed state but must still be duplicated for the engine to recognize them. This is where I believe there is an opportunity to simplify the process.

Proposed Solution

I propose introducing a pseudo-class counterpart to @starting-style to eliminate the need for duplicating styles:

dialog {
  transition: all 0.25s ease-out allow-discrete;
  &:not([open]), &[open]:starting-style {
    transform: translateY(-50%);
    &, &::backdrop {
      opacity: 0;
    }
  }
}

This syntax informs the engine about the state it is animating from without requiring the developer to duplicate styles. If we want to set styles for the open state as well, we can do so like this:

dialog {
  transition: all 0.25s ease-out allow-discrete;
  &, &[open]:starting-style {
    transform: translateY(-50%);
    &, &::backdrop {
      opacity: 0;
    }
  }
  &[open] {
    transform: translateY(0);
    &, &::backdrop {
      transition-duration: 0.5s;
      opacity: 1;
    }
  }
}

Conclusion

By introducing a pseudo-class :starting-style counterpart to @starting-style, we can avoid requiring developers to redeclare styles "before-state" styles inside @starting-style. This proposal aims to improve the developer experience and make @starting-style more approachable for simpler use cases while retaining its power and flexibility for more complex scenarios.


Some related notes re CSS Mixins (TL;DR: they don't solve this problem)

While [CSS Mixins](https://github.com/w3c/csswg-drafts/issues/9350) will simplify the process of managing styles for different states, I believe this enhancement would be most valuable as an addition to `@starting-style` itself, included in the `selectors` spec. It provides a more straightforward solution for common use cases, reducing redundancy in CSS code and making it easier for developers to define and manage animations between states. The below examples all assume these mixins are present: ```css @mixin --dialog-closed { transform: translateY(-50%); &, &::backdrop { opacity: 0; } } @mixin --dialog-open { transform: translateY(0); &, &::backdrop { opacity: 1; } } ``` Even using CSS Mixins, the mixins would still need to be invoked again within `@starting-style`: ```css dialog { @apply --dialog-closed; &[open] { @apply --dialog-open; } @starting-style { &[open] { @apply --dialog-closed; } } } ``` Now, with `:starting-style` and mixins: ```css dialog { &, &[open]:starting-style { @apply --dialog-closed; } &[open] { @apply --dialog-open; } } ```

una commented 3 months ago

Tagging in @josepharhar and @mfreed7 for your thoughts on this and the current implementation

(Also want to bring up the usecase where the entry and exit animation are different for consideration)

josepharhar commented 3 months ago

I agree that using a pseudo-class instead of an at-rule would be better for deduplication of style rules. This was decided here: https://github.com/w3c/csswg-drafts/issues/8174

It looks like the original proposal was a pseudo-class but was changed to an at-rule. I'm not sure if it was due to developer ergonomics, restrictions on what you can put inside of the pseudo-class, ease of implementation/spec, or some combination of the 3.

brandonmcconnell commented 3 months ago

Either of these proposals would resolve this issue if resolved as accepted by the CSSWG:

Thanks, @argyleink, for telling me about #6247.

dbaron commented 3 months ago

The first prototype used a pseudo-class. I think the discussion starting from https://github.com/w3c/csswg-drafts/issues/8174#issuecomment-1451132927 may be informative as to how we ended up with the at-rule. I think one of the big reasons to move away from that was that it was really confusing what things like div:initial p would mean and whether there was a reasonable way to make it mean something that matched expectations for what authors of CSS would expect it to mean.

I agree that we ended up with a solution that requires a bunch of repetition that I'm not happy about.

I wonder if a pseudo-element might have been a better balance. That was the one of the three things discussed that never got prototyped...

dbaron commented 3 months ago

(In slightly more detail as to the concern I mentioned: is div:initial p different from div p:initial... and if so, how? If it isn't different, does :initial really make sense as a pseudo-class? If it is different... is the difference implementable?)

brandonmcconnell commented 3 months ago

@dbaron Thanks for the extra context! That helps to understand.

I think the most compelling example to me so far is this one, which aligns with the logic proposed in #6247:

dialog {
  &, &[open]:starting-style {
    transform: translateY(-50%);
    &, &::backdrop {
      transition: all 0.25s ease-out allow-discrete;
      opacity: 0;
    }
  }
  &[open] {
    transform: translateY(0);
    &, &::backdrop {
      opacity: 1;
    }
  }
}

I've left a comment on that ticket context related to @starting-style.


To answer your question about div:starting-style p vs. div p:starting-style, I think they would yield the same effect.

These—on the other hand—would not, as some of the styles exist under the div selector but outside the p selector's usage of :starting-style in Example 2:

/* Example 1 */
div:starting-style {
  background-color: blue;
  p {
    /* some styles */
  }
}

/* Example 2 */
div {
  background-color: blue;
  p:starting-style {
    /* some styles */
  }
}
dbaron commented 3 months ago

To answer your question about div:starting-style p vs. div p:starting-style, I think they would yield the same effect.

So I think this is a core part of the problem. I don't think there's any other CSS pseudo-class for which that is the case, and I think that answer is a pretty clear sign that this isn't a pseudo-class. (But it still might be a pseudo-element.)

brandonmcconnell commented 3 months ago

@dbaron I meant to include another example which I would parallel this to. When using an at-rule, you’re usually matching selectors based on the state of an upper scope, usually the document.

In that way, these two are like, as they both match a selector based on an upper-scope state:

a { @media screen { … } }
b { a & { … } }

Similarly, both of these pseudo-classes do the same:

a:media(screen) { … }
b:is(a *) { … }

Here, I am using :media() as proposed in #6247, for the sake of the example.

By that reasoning, I would argue that there are in fact pseudo-classes that operate near identically to that one, where a:is(:root *) b { … } and a b:is(:root *) { … } yield the same effect.

Loirooriol commented 3 months ago

a:is(:root *) b { … } and a b:is(:root *) { … } yield the same effect

They don't, the former prevents a from being the root.

data:application/xhtml+xml,<a xmlns="http://www.w3.org/1999/xhtml"><style>a:is(:root *) b { border-top: solid } a b:is(:root *) { border-bottom: solid }</style><b>foo</b></a>

brandonmcconnell commented 3 months ago

@Loirooriol I should have provided the sample DOM structure for my example. My apologies.

For this exact & specific sample DOM structure:

<html>
  <body>
    <div class="a">
      <div class="b">…</div>
    </div>
  </body>
</html>

Both .a:is(:root *) .b { … } and .a .b:is(:root *) { … } yield the same effect, as per this example, both .a and .b are under the :root, for this specific example. Example: https://codepen.io/brandonmcconnell/pen/wvbGQrQ/2c3bd4ba8d19d211524a7be68c9ae8c8

Generally speaking, the two selectors clearly have different meanings and cannot be used interchangeably.

The general idea I am attempting to demonstrate is that a :when(), :media(), :supports(), :starting-style(), etc. pseudo-class would not be the first pseudo-class to allow matching a selector based on an upper scope.