Skip to content
AtomicReps

Animate height to auto without a guessed max-height

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

Level I · CSS that replaces JavaScript
  • The transition ran for 180 milliseconds without changing the panel's height.

    The common read: a max-height transition animates the panel, so the panel starts closing on the first frame.

    The panel was 4000px tall and stayed at exactly 4000px for the first 180ms of a 300ms close. The ceiling is 9999px and the content is 4000px, so the ceiling has to drop 5999px before it reaches the content height, and 5999 of 9999 pixels at a constant rate is 180 of 300 milliseconds. The pause equals the duration multiplied by the share of the ceiling above the content. Double the ceiling to 19998px for a taller panel, and the pause grows to 240ms.

    Measured in Chromium, Firefox and WebKit at a 1280 by 800 viewport, the last frame reading exactly 4000px is at 175.2ms, 174.2ms and 166ms, and the first frame below 4000px is at 183.5ms, 182.54ms and 183ms. Frame timing sets the width of that bracket, and 180ms is inside it in all three.

    9999 was a guess. A bigger guess makes the pause longer.

  • max-height caps the used height. The transition is arithmetic on a number.

    The common read: the browser knows how tall the content is, so a max-height transition measures the box and animates what is visible.

    No step of that transition reads the content height. Length values interpolate as real numbers on the computed value, so every frame the browser calculates a number between 9999 and 0. Applying max-height is a separate step: it only stops the used height from exceeding the specified value, so while the ceiling is above the content height the used height does not change. The old model worked when the guess was close: a 400px ceiling on a 380px panel leaves no visible pause.

    The first frame after 120ms of the close shows both steps. el.getAnimations() returns one transition on max-height with playState "running" and currentTime 125 in Chromium, 124.12 in Firefox and 133 in WebKit. getComputedStyle(panel).maxHeight reads "5832.75px", "5861.93px" and "5566.109863px" in the same order, and panel.getBoundingClientRect().height reads 4000 in all three. The animation is running and the box has not changed height.

  • height: auto was never unanimatable. The pair of values has no midpoint.

    The common read: auto is a keyword and keywords are not numbers, so height is not an animatable property and the browser drops the transition.

    The rule is in the file, the class changes, and the panel is at full height on the next frame. Which part of the transition did the browser skip?

    The browser did not skip the property. height is animatable by computed value type: the browser combines the components of the two computed values, and a length against auto is a component-type mismatch, so the pair combines as discrete. With a plain transition, a discrete pair starts no transition at all. That is a fact about the PAIR, not the property. "Not animatable" and "no midpoint" predict the same thing here, and a later screen shows a case where they predict opposite things. Measured in all three engines:

    height: 0 to height: 4000px animates: the first frame after the class change reads 110.656 in Chromium, 111.333 in Firefox and 213.328 in WebKit, and the panel passes through dozens of intermediate heights. height: 0 to height: auto fires zero transitionstart and zero transitionend events, el.getAnimations() is empty on every sampled frame, and the panel is at its full 4000px on the first frame.

  • Delete the hook. Animate the grid row instead.

    The common read: CSS has no way to know how tall the content is, so the height has to be measured in JavaScript and written back.

    Open the panel, let a filter row render into it while it is open, and the height in the style attribute is a number measured from a layout that no longer exists. Everything the hook grows is there to keep that number current:

    the swap to auto on transitionend, so the panel can grow with its content the observer, so a content change re-measures the teardown, so the observer does not outlive the panel the extra animation frame, so the browser has a starting height to animate from

    The hook was the right answer when it was written, because reading scrollHeight was the only way to get a height CSS did not provide. A grid whose single row is 0fr closed and 1fr open animates a track list instead, the clipped child is sized by the track, and the browser calculates the number from the content. The grid technique ships in every browser. You lose the inline style attribute your tests asserted on, and content that changes while the panel is open needs no code.

  • A 1fr row on a grid with no height is a row the size of its content.

    The common read: fr is a share of the leftover space, so 1fr on a panel with no declared height is a share of nothing and collapses.

    What is 1fr a share of here? The panel has no height of its own and nothing is left over, so by that definition the open state should be as flat as the closed one, and you have watched the panel open.

    When the grid container's height is indefinite, a flexible track is sized to its content, as its max-content contribution divided by its flex factor, not against free space. One track with a factor of one resolves to the content height, so 1fr and auto give the identical box: measured in all three engines with a 4000px child, gridTemplateRows reads "4000px" for both. Give the grid a definite 500px height and the same 1fr reads "500px", the leftover-space branch. That content-height number can be interpolated; height: auto cannot.

  • The track list is compared by length before it is compared by value.

    The common read: The transition animates the row, so adding a second row changes what is on screen and not whether it animates.

    Two computed track lists are interpolable when they have the same number of tracks, and when they do not, the pair combines as discrete, which under a plain transition means no transition is started at all. Nothing warns, because the count belongs to the pair of values, not to either rule. A mismatch does not give a worse animation, it gives none: matched counts give dozens of intermediate heights across the 300ms in all three engines, and a mismatch gives one height and no transition events.

    Keep the animating template to ONE track and put every other row outside the grid that animates. It costs one wrapper element, and it protects against the three usual causes, which are all edits to something other than the transition:

    a conditionally rendered row gets its own track a repeat() resolves to a different number of tracks on one branch a shorthand on one state lists tracks the other state does not

  • A 0fr row reaches zero only when the item's minimum size is zero.

    The common read: 0fr sizes the row to zero, so the row is zero whatever the item inside it does.

    A grid item's automatic minimum size is its content-based minimum, and the used track size is the larger of the track's size and the item's minimum, so a 0fr row reaches zero only when the item's minimum is zero. Of the three conditions that give an item a zero minimum, one matters here: an item whose computed overflow is a scrollable value is a scroll container, and a scroll container's automatic minimum size is zero. hidden is a scrollable value.

    min-height: 0 sets the same zero minimum directly. Measured in all three engines, the panel collapses to 0 with overflow: hidden and no min-height, and to 0 with min-height: 0 and no overflow, so on an item that already clips itself the declaration is redundant. It stops being redundant when the clipping moves to another element, the refactor that produced the panel that never closes. Declare it on the animating item whenever the clipping is not on that item, with a one-line comment naming what it disables.

  • Put the padding on the child, not on the item that animates.

    The common read: Padding is inside the box, so a box the animation drives to zero takes its padding to zero with it.

    Stretching sets the item's outer size, padding is part of that outer size, and overflow clips against the padding box, so a padded clipped row stops shrinking at its own padding height rather than at zero. No new rule applies: three familiar rules combine on a box whose content height is zero, which a static layout never produces, so the strip under the closed panel is hard to trace.

    Measured in all three engines, the same padding: 16px settles the panel at 32px on the animating item and at 0 on the child, with nothing else changed. So the box that animates needs zero intrinsic size: it gets overflow: hidden and nothing else, and every declaration that adds size (padding, borders, a minimum, a line box of its own) moves to the child inside it. When you inherit a panel that closes to a strip, read the element the transition names first.

  • interpolate-size: allow-keywords makes auto an endpoint. One engine supports it.

    The common read: the platform fixed animating to auto, so the grid technique is a workaround I can retire.

    What would it take to delete the wrapper, the clipped child and the track list, and animate height: 0 to height: auto directly? It takes one declaration, and that declaration does not go on the element you would expect.

    interpolate-size: allow-keywords allows interpolation between a <length-percentage> and an intrinsic size value, so height: 0 to height: auto becomes an ordinary interpolation. calc-size() uses the same mechanism and adds arithmetic on an intrinsic keyword. Both are Chromium only today: Firefox and Safari do not have them yet, so this lesson covers them for recognition, not for shipping. Four facts decide how they behave:

    What counts is the value on the animating element when the transition starts, which is the new state's style. Put it only in the open rule and the panel animates open but snaps shut. The property is inherited, so one declaration on :root covers every element in both directions. A value written with calc-size() interpolates without the opt-in, because the opt-in exists only for plain keywords such as auto. Measured with the property on an ancestor, Chromium reads 1666.656px at currentTime 125ms of a 300ms open, with one running transition on height. Firefox and WebKit are at the full 4000px on the first frame with zero transition events, because an engine drops a declaration for a property it does not recognise.

    Someone tells you the platform fixed animating to auto, and the accurate one-line version is that

  • Ship the grid row. Add the keyword when every engine supports it.

    The common read: the technique that ships everywhere is the fallback, so the Chromium keyword is the real answer and the grid version is what I write until I can delete it.

    Which of the two is the workaround?

    The grid version is not a polyfill for the keyword: it animates a different property, works in every engine today, and costs one extra element for the clipping. Adopt the keyword when the last engine ships; treat the grid version as temporary and you write the panel twice. Until then, use the keyword only where the panel may snap open without animating, as it does in Safari. A feature query answers correctly for this property, which is not true of every feature:

    CSS.supports("interpolate-size", "allow-keywords") returns true in Chromium and false in Firefox and WebKit. CSS.supports("height", "calc-size(auto, size)") returns the same three answers.