Skip to content
AtomicReps

Animate in and out of display: none without a timer

A lesson from Modern CSS: Features That Replace JavaScript. Play it above, or read it through below.

Level I · CSS that replaces JavaScript
  • 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 with their ordinary specificity, so a later rule of equal specificity overwrites them before the browser samples them as a from-state. This cascade rule is why the block goes AFTER the rule whose starting values it sets. The at-rule adds no specificity, no priority and no separate cascade step; it groups declarations the way @media groups them, and the cascade orders grouped declarations by their selector and their position in the file.

    A from-state has to be supplied because a transition needs a before-change style and an after-change style. 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 computed again with the matching @starting-style rules added to the cascade. The ordinary cascade applies them, so an equal-specificity rule below the block overwrites the starting declarations, the substituted style equals the end state, and two identical values start no transition.

    @starting-style is supported in every browser. Support was added recently, so check it against the oldest browser versions you support. The placement rule holds in every version, because it is a cascade fact rather than an implementation detail, and it is also why the nested form is the better default: written inside the rule whose starting values it sets, @starting-style cannot be moved above that rule by an editor, a sort-properties tool or a merge.

    The browser parsed the rule and did not animate the palette. The specification requires this behaviour.

  • A transition cannot run on an element that the same style change stops rendering.

    The common read: The transition that faded the element in fades it out, so removing the 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 never runs. This is a different failure from the discrete-pair failure you already know.

    display: none against display: block is also a discrete pair and a discrete pair starts no transition, but that is not why the exit fails here: display was never in the transition list, so the browser never tried to animate it; the value changed, and in the same style change the element stopped being rendered.

    The fix is one entry in the transition list: display 300ms allow-discrete. This list item adds display to the list, and its allow-discrete keyword sets transition-behavior: allow-discrete, which lets a discrete pair transition, so the browser holds the old display value for the duration instead of applying the new one at once. A discrete value does not interpolate: it flips once, and the point where it flips is the part to remember.

    A none value is used only at its own end of a display transition, so a flip from none happens at 0% and a flip to none happens at 100%. On the way out, this keeps the box painted for the whole fade. On the way in, no display transition runs, because the starting style already has display: block. That is why the first stylesheet in this lesson fades in with only opacity in its list.

    This behaviour is not specific to display; a group of properties shares it. A property that decides whether the element is SEEN keeps its visible value for the whole transition: a test 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, as the popover section later in this lesson shows.

    Other discrete pairs usually flip near the middle, but not always. The same test measured border-style flipping at 150.1ms, 157.36ms and 165ms and text-transform at 157.9ms, 150.36ms and 151ms, and it measured lesson 1's grid-template-rows: 0fr against 1fr 40px flipping at 150.88ms in Firefox, 166ms in WebKit and 302.8ms in Chromium. So do not remember a midpoint rule from these measurements.

    Remember the group: when the value decides whether you can see the box, the browser keeps the visible value until the end.

    Delete the timer. The JavaScript hook that imitates this behaviour does two jobs, and the browser now does both: it forces a frame on open so there is a value to transition from, and it runs a setTimeout on close with the same duration that is already written in the stylesheet. The setTimeout duration goes out of date because it is a copy: a common bug is a duration changed in the CSS and not in the JavaScript, which hides the element before its fade ends.

  • Every browser parses the declaration, and Firefox does not yet transition display with it.

    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 the same way it does in Chrome, and the palette disappears on the first frame of the close.

    transition-behavior and its allow-discrete value are supported in every browser. Transitioning display is a separate feature, and Firefox does not support it yet, so the declaration parses, the feature query returns true, and the close is instant.

    Measured in Chromium, Firefox and WebKit with the fade-out stylesheet from earlier in this lesson: 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 only tells you whether the browser parses the declaration, and here parsing and behaviour differ, so do not use this feature query as a condition.

    Ship the pattern anyway, and know which half does not animate in Firefox. The entry animates in all three engines, and it is the half users notice; the exit falls back to the instant close that every browser had before this feature existed, which is the behaviour the old setTimeout worked around, and the palette still opens and closes.

    Do not add the timer back as a fallback, because then the two engines that animate the close run your timer against their own transition, and you replace one engine's missing animation with a race condition in the other two.

  • The browser defers the two values that hide the element until the transition ends.

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

    During the close, the box stays on screen until the transition ends, and two separate deferrals keep it there.

    display: none waits because display is in the transition list with allow-discrete, which is the panel case from earlier in this lesson. Leaving the TOP LAYER, which is where a popover or a modal dialog is painted while it is open, is a second, separate removal, and the browser defers it only when overlay is in the transition list too.

    Without overlay in the list, the element is still rendered but is no longer painted above everything else. In a simple demo nothing visible changes; on a real page the popover disappears behind the element it was covering while its fade is still running. The paint-order difference was measured in Chromium on a fixture that defers display and overlay and does not fade, so the box stays opaque and the pixel reading depends only on paint order.

    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 covering layer. Without overlay in the list, 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, the browser ignores an author declaration for it, and you can use it only as a name in transition-property, so that the browser waits for the transition duration before it removes the element from the top layer. Only Chromium supports it today.

    Firefox and Safari do not support it yet, so learn the property so that you recognise it, not so that you ship it. overlay is the one part of the exit that the panel version did not need.

  • The browser uses the starting style only when the element has no before-change style.

    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 the browser never uses it for an element that was rendered a frame ago. Two elements can carry the identical block, and the browser uses the block for only one of them. Which element that is depends on the closed state, not on the block.

    So the entry animation depends on the closed state. display: none leaves the element with no before-change style after every close, so every open goes through the starting style again: the same test 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 the element rendered and makes the at-rule dead code, and no tool reports it.

    So make the closed state the state that is not rendered, and accept its two consequences. 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, so there is no initialisation step and nothing to reset between opens.

  • 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.

    This pattern fails silently in three places: starting styles written above the open rule, a closed state that keeps the element rendered, and a transition list without display. Learn the order as a rule rather than as a shape you recognise.

    Write the closed state first, with display: none and the whole transition list: the closed state decides whether the browser uses the starting styles, and the transition list decides whether any removal waits for a duration on close. Write the open state second. Put the starting styles last, or nest them inside the open rule, where no later edit can move them above it.

    Add overlay to the transition list on anything in the top layer, and accept that only Chromium defers the top-layer removal today. Then test the exit in a second engine before you finish, because the entry works in every engine and the entry is the half you will look at.

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