Skip to content
AtomicReps

9999 was a guess

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

  • The transition ran for 180 milliseconds without moving a pixel.

    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. Nothing was stuck. The declared ceiling is 9999px and the content is 4000px, so the falling ceiling has 5999px to cover before it touches anything you can see, and 5999 of 9999 pixels at a constant rate is 180 of 300 milliseconds.

    Measured on the pinned engines at a 1280 by 800 viewport, the last frame reading exactly 4000px lands at 175.2ms in chromium, 174.2ms in firefox and 166ms in webkit, and the first frame below 4000px lands at 183.5ms, 182.54ms and 183ms. Frame quantisation is the whole width of that bracket, and 180ms sits inside it in all three.

    The number moves with the guess, which is the part worth carrying: raise the ceiling to be safe for a taller panel and the pause gets longer, in direct proportion.

    9999 was a guess. It was a good guess. It was still a guess.

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

    Nothing in that transition ever looked at the content. Length values interpolate as real floating-point numbers on the calculated value, so the browser was handed 9999 and 0 and did arithmetic between them, every frame, with no knowledge of what the box was holding.

    What max-height then does with the result is a second, separate step: it prevents the used value of height from becoming larger than the value specified, which means that while the interpolated ceiling is above the content height it changes the used height by nothing at all. Two definitions, and the dead pause is what falls out of putting them next to each other.

    The receipt is the first frame past 120ms of that 300ms close, which each engine hands back at its own cadence: 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 that same order, and panel.getBoundingClientRect().height reads 4000 in all three.

    A running animation and a still box, on the same frame. The old model cost nothing for years and here is where it was true: pick 400px as the ceiling for a panel that runs to 380px and the cap and the height are the same number, so the guess and the content agree and no pause exists.

    Stop looking for the frame where the browser starts measuring: there is no such frame, and any technique built on a guessed ceiling pays the difference between the guess and the content in time.

  • 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 flips, and the panel is at full height on the next frame. Which part of the transition did the browser skip?

    Not the property. Change one endpoint and the same declaration animates: with height: 0 transitioning to height: 4000px, the first frame after the class change already reads 110.656 in chromium, 111.333 in firefox and 213.328 in webkit, and the panel passes through dozens of intermediate heights on its way up.

    height is animatable by computed value type, which means the browser combines corresponding components of the two computed values, and when one endpoint is a length and the other is the keyword auto the component types do not match, so the pair falls back to discrete. That is a fact about the PAIR, not about the property, and the consequence is bigger than it looks: with a plain transition a discrete pair does not start a transition at all.

    Measured on height: 0 to height: auto in all three engines, zero transitionstart events fire, zero transitionend events fire, el.getAnimations() returns an empty list on every sampled frame, and the panel is at its full 4000px on the first frame after the class change. Hold the mechanism rather than the folklore, because "not animatable" and "no midpoint between these two values" predict the same thing here and predict opposite things the moment a track list changes length.

  • Delete the hook. The row is the animation.

    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 sitting in the style attribute is a number from a layout that no longer exists. Chasing that is what the hook keeps growing to do.

    The hook was the right answer, and it was the right answer because the platform gave you nothing else: to animate away from a height you first have to have one, and reading scrollHeight and writing it back as an inline value was the only way to manufacture the number CSS would not supply.

    Everything that hook does after that is maintenance of the lie, which is why it grew: 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 forced frame so the browser has a number to leave from.

    Replace it with a row. A grid whose single row is 0fr in the closed state and 1fr in the open one animates a track list rather than a height, the clipped child rides the track, and the number the browser interpolates is the one it worked out from the content on its own. This ships in every browser.

    The one thing you give up is the inline style attribute your integration tests were asserting on, and the one thing you gain is that a panel whose content changes while it is open needs no code at all.

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

    A share of what, exactly. 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 it open.

    When the grid container's size in an axis is indefinite, flexible tracks are not sized against free space at all: they are sized to their contents, keeping their proportions, by taking each flexible track's max-content contribution and dividing by its flex factor. One track with a flex factor of one means the row resolves to the content's max-content height, which is why grid-template-rows: 1fr and grid-template-rows: auto produce the identical box here.

    Measured on the pinned engines with a 4000px child: getComputedStyle(panel).gridTemplateRows returns "4000px" for 1fr and "4000px" for auto, and the panel is 4000px in both. Give the grid a definite height of 500px and the same 1fr returns "500px", which is the other branch of the same algorithm doing what you expected all along.

    Read 1fr as "as tall as the content, expressed as a number the browser can interpolate", because that is the whole reason this technique animates and height: auto does not.

  • 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. That gate is invisible in the stylesheet: nothing warns, the declarations both parse, and the count is a property of the two values rather than of anything you can point at in one rule.

    Breaking it by accident is easy. Attributing it afterwards is not. The three usual ways in 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, and a shorthand on one state lists tracks the other state does not.

    The measured difference is total rather than degraded: matched counts give dozens of intermediate heights across the 300ms in all three engines, and a mismatch gives exactly one height and no transition events at all. Keep the animating template to ONE track and put every other row outside the grid that animates, which costs one wrapper element and makes the gate impossible to trip from a component that renders conditionally.

  • 0fr asks for zero. The item decides whether it gets it.

    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 what the track asked for and what the item will accept, so a row that asks for zero gets zero only when the item agrees. Three conditions make an item accept zero and only one of them is worth remembering: 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. That one fact is the reason every copy-paste version of this technique carries min-height: 0 with no explanation attached.

    min-height: 0 on the animating item is the direct way to say the same thing, and on an item that already carries overflow: hidden it is genuinely redundant rather than merely harmless: measured, the panel collapses to 0 with overflow: hidden and no min-height, and it collapses to 0 with min-height: 0 and no overflow, in all three engines.

    It stops being redundant the moment the clipping moves to a different element, which is the refactor that produced the panel that never closes. Declare min-height: 0 on the item you are animating whenever the clipping does not live on that same item, and take the honest cost: it is a declaration whose reason is invisible at the call site, so it is worth the one comment line that names what it disables.

  • The padding belongs to the child. The row that animates owns nothing.

    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 bottoms out at its own padding rather than at zero. You already know every rule in that sentence.

    That is why the strip under the closed panel is so hard to attribute: nothing new is happening, three familiar rules are meeting on a box whose content height is being driven to zero, and a static layout never puts them in that position.

    The rule that falls out is short and it decides where every declaration on this element goes: the box that animates needs zero intrinsic size, so 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.

    Measured, the same padding: 16px settles the panel at 32px on the animating item and at 0 on the child, in all three engines, with nothing else changed. Put the padding on the child, and when you inherit a panel that closes to a strip, read the element the transition names before you read anything nested inside it.

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

    The common read: the platform fixed this, 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? One declaration, and it does not go on the element you would put it on.

    interpolate-size: allow-keywords makes interpolation legal between a <length-percentage> and an intrinsic size value, so height: 0 transitioning to height: auto becomes an ordinary interpolation with no wrapper element, no clipped child and no track list. It is inherited rather than per-element, which is why it is written on :root and why setting it in a narrow rule that matches only during the interaction does nothing at all.

    calc-size() is the same machinery with arithmetic attached, letting a value do maths on an intrinsic keyword, and it turns the opt-in on by itself for the element it is used on. Chromium only today. Firefox and Safari do not have it yet, so this is here for recognition, not for shipping.

    Measured on the pinned engines with the property set on an ancestor: the panel interpolates in chromium, reading 1666.656px at 123.8ms of a 300ms open with one running transition on height, and in firefox and webkit the same fixture is at its full 4000px on the first frame with zero transition events, because a property an engine does not recognise is a declaration it drops.

    Someone tells you the platform fixed this, and the accurate one-line version is that

  • Ship the row. Watch the keyword.

    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, it works in every engine today, and the only thing it costs is one element that carries the clipping. The keyword is not a replacement waiting to happen either, it is a simplification worth taking when the last engine ships, and the difference between those two framings decides whether you write this once or write it twice.

    So ship the row, and write the keyword version only where you can afford it to no-op, which is a narrower set of surfaces than an internal tool: a snapping panel in Safari is not a graceful degradation, it is the bug you opened this lesson to fix.

    A feature query is honest for this one, which is worth saying because it is not always true: measured on the pinned engines, CSS.supports("interpolate-size", "allow-keywords") returns true in chromium and false in firefox and webkit, and CSS.supports("height", "calc-size(auto, size)") returns the same three answers. Take the whole lesson as one sentence: find the number the browser can interpolate, then check that nothing else in the box has an opinion about how small it may be.