Skip to content
AtomicReps

The rule is in the file and nothing happened

A lesson from Modern CSS: What The Browser Took Back. Play it above, or read it through below.

  • The starting styles lose to any equal rule written below them.

    The common read: @starting-style is a rule like any other, so it can go anywhere the selector matches.

    The declarations inside @starting-style are resolved by the ordinary cascade at the ordinary weight, so a later rule of equal specificity overwrites them before the browser ever samples them as a from-state. That is the entire mechanism, and it is why the block goes AFTER the rule it primes. The at-rule gets no boost, no priority and no special phase; it groups declarations the way @media groups them, and grouped declarations sort where their selector and their position put them.

    What the browser is doing with them is worth one sentence, because it explains why a from-state has to be supplied at all. A transition needs a before-change style and an after-change style, and an element that was not rendered a moment ago has no before-change style for this style change, so the browser substitutes a starting style: the after-change style with any matching @starting-style declarations applied on top.

    Applied on top by the cascade. Put an equal-weight rule below the block and there is nothing left on top, the substituted style equals the end state, and two identical values start no transition.

    This ships in every browser. It landed recently enough that it is worth checking against your own support floor. The placement rule is the part that survives every version of that check, because it is a cascade fact rather than an implementation detail, and it is also why the nested form is the one to reach for by default: written inside the rule it primes, @starting-style cannot be lifted above it by an editor, a sort-properties tool or a merge.

    The rule is in the file. The browser read it. Nothing happened. This is not a bug report.

  • A style change that stops the element being rendered takes the transition with it.

    The common read: The transition that faded it in fades it out, so the closing class is all the exit needs.

    The closing style change stops the element being rendered, and an element that is not rendered runs no transitions, so the fade you declared has nowhere to happen. That is a different failure from the one you already know.

    display: none against display: block is also a discrete pair and a discrete pair starts no transition, but that half is not what kills the exit here: display was never in the transition list, so it was never asked to animate, it changed, and the change took the element out of existence in the same pass.

    Both halves are fixed by the same declaration and the fix is one keyword. transition-behavior: allow-discrete opts a discrete pair into transitioning, which is what makes display a property the browser will hold instead of apply. Held is the right word: a discrete value does not interpolate, it flips once, and where it flips is the part worth memorising.

    display flips at 0% going in and at 100% coming out, so the box is painted for the entire duration in both directions, which is the only arrangement in which a fade is visible at all.

    That is not a quirk of display, it is a family. A property that decides whether the element is SEEN holds its visible value for the whole run: run 4 measured visibility: visible to hidden flipping at 299.4ms in chromium, 300ms in firefox and 302ms in webkit of a 300ms linear transition, and overlay does the same thing five screens from here.

    Other discrete pairs usually flip near the middle, and usually is the honest word. The same run put border-style at 150.1ms, 157.36ms and 165ms and text-transform at 157.9ms, 150.36ms and 151ms, but lesson 1's grid-template-rows: 0fr against 1fr 40px at 150.88ms in firefox, 166ms in webkit and 302.8ms in chromium. So do not carry a midpoint law away from this screen.

    Carry the family: when the value decides whether you can see the box, the browser keeps the visible one until the end.

    Take the timer out. The hook that fakes this is doing two jobs, and both of them are the browser's now: a forced frame on the way in so there is a value to leave from, and a setTimeout on the way out holding the same duration that is already written in the stylesheet.

    That second number is the one that rots, because it is duplicated, and the version of this bug everyone has shipped is a duration changed in the CSS and not in the JavaScript, which hides the element halfway through its own fade.

  • The declaration ships everywhere. What it does to display does not.

    The common read: the feature query returned true, so the exit animation I just shipped runs in every engine.

    Ship that stylesheet and open it in Firefox. The entry fades exactly as it does in Chrome, and the close is over before the first frame.

    transition-behavior and its allow-discrete value ship in every browser. Making display transitionable is a separate thing, and Firefox does not have it yet, so the declaration parses, the feature query says yes, and the close is instant.

    Measured on the pinned engines with the S5 stylesheet: chromium samples 37 distinct opacity values on the way out and holds display: block until 308.2ms, webkit samples 18 and holds until 301ms, and firefox reads opacity: "0" and display: "none" on the first sampled frame with zero transition events and an empty getAnimations() list. CSS.supports("transition-behavior", "allow-discrete") returns true in all three.

    A feature query answers a parsing question, and this is one of the cases where parsing and behaviour come apart, so do not gate on it here.

    Ship it anyway, and know which half degrades. The entry animates in all three engines and it is the half users read as polish; the exit degrades to the instant close everyone had before this feature existed, which is the behaviour the old setTimeout was faking and is not a broken screen.

    What you do not do is write the timer back as a fallback, because then the two engines that animate the close run your timer against their own transition, and you have traded one engine's missing animation for a race condition in the other two.

  • The two declarations that end the animation are the two that wait for it.

    The common read: the exit animation keeps the element around by itself, so a fade on a popover needs nothing the fade on a panel did not need.

    The close is running and the box is still on screen. What is holding it there?

    Two deferrals, and they are separate. display: none waits because display is in the transition list with allow-discrete, which is the panel case you already have. Leaving the TOP LAYER, which is where a popover or a modal dialog is painted while it is open, is a second removal on a second schedule, and it is deferred only when overlay is in the transition list too.

    Without that, the element keeps being rendered and stops being painted above everything else, which in a flat demo looks like nothing and in a real screen is the popover vanishing behind the thing it was covering, halfway through its own fade.

    Measured in chromium, on a fixture that defers display and overlay and does not fade, so the box is opaque the whole way and the pixel reading is about paint order and nothing else: with overlay in the list, getComputedStyle(palette).overlay still reads "auto" 120ms into the close and the pixel at the palette's centre is still the palette's own colour, so the palette is still painted over the layer above it; without it, overlay reads "none" on the first frame while display still reads "block", and that same pixel is the covering layer.

    overlay is the one property in this pattern you can never assign. The user agent sets it, an author declaration for it is ignored, and its only job is to be a name you put in transition-property so that the removal it controls has a duration to wait for. Chromium only today.

    Firefox and Safari do not have it yet, so this is here for recognition, not for shipping, and the recognition is worth having because it explains the one part of the exit that the panel version never had to think about.

  • The starting style is a substitute, and it is only reached when something is missing.

    The common read: @starting-style is the from-state for every open, so an element that opens twice fades from the same value twice.

    A starting style is not a state the element enters, it is the value the browser uses when there is no before-change style to compare against, so an element that was rendered a frame ago never reaches for it. Two elements can carry the identical block and only one of them will ever use it. The block is not the variable. The closed state is.

    Which is why the closed state is the load-bearing half of this pattern. display: none puts the element back into the no-before-change-style case on every close, so every open goes through the starting style again: run 9 measured a second open at "0.0263233" in chromium, "0.0575357" in firefox and "0.05" in webkit, from the same block that the always-rendered fixture ignored on both opens.

    Hiding by opacity alone, or by moving the element off screen, keeps it rendered and quietly turns the at-rule into dead code that no tool will flag.

    So write the closed state as the state that is not rendered, and take the two consequences with it. The element leaves the accessibility tree between opens, which is what you want for a palette and what you must check for anything a screen reader is meant to reach while closed. And the entry animation is now the same mechanism on the first open and the hundredth, which is the property that makes this pattern worth having: there is no initialisation step, and nothing to re-arm.

  • Write the closed state first, and put the starting styles last.

    The common read: the three declarations are a snippet, so I can copy the block and change the duration.

    Two of the three declarations in this pattern do nothing on their own, and each of them fails silently in a different place. That is the whole reason to hold the order as a rule rather than as a shape you recognise.

    Closed state first, carrying display: none and the whole transition list, because the closed state is what decides whether the starting styles are ever reached, and the transition list is what decides whether anything at all has a duration to wait for on the way out. Open state second. Starting styles last, or nested inside the open rule where nothing can lift them.

    Add overlay to the transition list on anything in the top layer and accept that the deferral it buys is Chromium's today. Then check the exit in a second engine before you call it done, because the entry is the half that works everywhere and the entry is the half you will look at.

    Say where the starting-style block goes, in one line, without looking: