Skip to content
AtomicReps

Sixty times a second, to confirm the box had not moved

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

  • The browser is running your scroll listener now.

    The common read: positioning one box against another is geometry the platform does not do for you, so the numbers have to come from getBoundingClientRect and be written back every frame.

    That decision, re-made at every step, is the whole job the listener existed to do: read where the trigger is, decide which side the tooltip fits on, write the numbers back, and do it again the moment anything moves. Two CSS properties hold the relationship instead.

    anchor-name marks the trigger as an anchor under a name of your choosing, position-anchor on the tooltip says which named anchor this element is positioned against, and from there anchor() reads the anchor's edges into inset properties and position-area places the tooltip in one of the nine regions around it.

    Chrome, Firefox and Safari all ship anchor positioning, and they spent its entire rollout disagreeing about what an unset position-anchor resolves to, which is why the second of those two properties is written out in every listing in this lesson.

    The listener was right, and it was right because the platform gave you no way to say "under that element" in a stylesheet. Everything that listener grew after the first version is maintenance of that gap: the scroll handler, the resize handler, the frame throttle so the reads do not tank the main thread, the flip logic for the last row in the table, the teardown so none of it outlives the tooltip. None of it was ever about your product.

    Takeaway: Sixty times a second, to confirm the box had not moved.

    The trade is real and it is small. You give up the ability to read the position back out of an inline style attribute, which is what your integration tests were asserting on, and you give up doing anything clever with the number, because you no longer have it. What you get is a tooltip that is correct during a scroll you never subscribed to.

  • An unset position-anchor is not neutral.

    The common read: The anchor name on the trigger is what creates the link, so position-anchor is a convenience that saves repeating the name.

    Naming an anchor inside anchor() positions the element against it and does not associate the element with it, so a declaration that asks for the default anchor gets nothing at all when no position-anchor supplies one. The default anchor is what a bare anchor(bottom) reads, what anchor-size() reads, and what position-area places against, which means three of the four ways to use this feature go through the one property people leave out.

    On the three pinned engines the unset value reads back as normal. The specification says normal behaves as no anchor when position-area is none and as the implicit anchor otherwise, and the second half of that is not what the engines do: a popover with position-area set and no position-anchor follows the button that opened it in none of chromium, firefox or webkit (run 9).

    So the settled answer is younger than most of the browsers your users are on, and half of it has not arrived yet.

    The line you would have written a year ago is that

    So write it out, on every anchored element, even the ones where a named anchor() is currently doing the work. The cost is one declaration. The alternative is a property whose meaning is decided by which browser build your user happens to have, on the one behaviour that decides whether the feature works at all.

  • Nine positions, one property, no arithmetic.

    The common read: an anchored element is placed by its inset properties, so every position is a pair of anchor() calls and an offset you work out yourself.

    How many declarations does it take to say "under the badge, centred on it"?

    One. position-area divides the space around the anchor into a three by three grid, with the anchor itself in the middle cell, and puts the element in the region you name: block-end is the row under the anchor, inline-end is the column beside it, block-start inline-end is the corner above and after it.

    The physical keywords (top, bottom, left, right) and the logical ones (block-start, block-end, inline-start, inline-end) both work, and the logical family is the one that keeps behaving when a locale flips the inline direction. This is the property @position-try is written in and the one a fallback chain names most often, so the grid is worth holding as a picture rather than as a keyword list.

    Reach for the inset form when you need an offset the grid has no cell for, and reach for position-area the rest of the time, which is nearly always.

  • The first candidate that fits ends the loop. If none fit, the browser goes back to the start.

    The common read: the fallback chain degrades step by step, so when the last option still overflows the browser keeps it, because it was the closest thing to fitting.

    The first candidate that fits ends the loop. What happens when none of them fit?

    Not the last one you wrote. The browser lays the element out at its base position, tests whether that box is contained, and if it is not, walks the comma-separated list in written order, applying each candidate and running the same containment test on the result. The first candidate that passes wins and the walk stops there.

    If every candidate is tried and every one still overflows, the browser discards all of them and returns the element to the base position it started from, which can leave a tooltip overflowing in exactly the place your first fallback was written to rescue it from.

    Measured on the pinned engines with a 1000 by 500 tooltip too wide for any region around its badge: the base position alone renders the box at top 300, left 0, the first candidate alone at top 0, left 0, the second candidate alone at top 142, left 280, and the full chain renders it at top 300, left 0, which is the base box to the pixel in all three engines.

    Two consequences worth carrying, and neither is about writing more candidates. A chain that ends in an option small enough to always fit is a chain that never reverts, which is what a narrower @position-try block with its own width is for. And the symptom of a chain that ran out is a tooltip sitting exactly where it sat before you wrote any fallbacks, which reads as "the fallbacks are not being applied" and is the opposite: they were all applied, tested, and rejected.

  • Nothing flips unless you have listed something to flip to.

    The common read: Overflow avoidance is what anchor positioning is for, so a tooltip the browser is positioning stays on screen.

    Nothing moves the tooltip to a different side of the badge unless you have listed a candidate that does, because the fallback chain is the only machinery in this feature that changes which position is used. What you measured on the previous screen is two placement models being honest about themselves: a region gets a box fitted into it, an inset pair gets arithmetic, and neither of them is a decision about whether the result is a good place for a tooltip.

    The old model was true and it was true because it was true of the tool: Popper flipped by default and Floating UI ships flip as a middleware you add, so an engineer who has used either one has watched collision handling happen without asking for it at least once.

    The prescription is one line and it goes on every anchored element you ship: position-try-fallbacks, with at least one candidate, chosen so the last one on the list is small enough or free enough to fit. flip-block and flip-inline cost nothing and cover both common edges. A named @position-try block is what you reach for when the flipped position needs its own width, its own margin, or its own alignment, and it is also the option that stops the chain reverting to a base position that overflows.

  • A name is published, not owned.

    The common read: An anchor name is like an id, so two elements claiming the same one is a mistake and the first one to claim it wins.

    An anchor name is published rather than owned, so when several elements publish the same one, a reference resolves to the last of them in source order, silently and correctly, with one divergence: chromium and webkit resolve to the last publisher that precedes the referencing element, firefox to the last publisher in the document, and the two readings agree whenever the reference is written after every publisher (run 12).

    That is a reasonable rule and it is a hostile one for the way you write components, because a component that declares anchor-name in its own stylesheet publishes that name once per instance and there is no instance boundary in a stylesheet. The fix that scales is a name per instance, written from the data you already have, which in a table means the row's own identifier reaching the element as a custom property set on the instance.

    Set that property in the component's stylesheet instead and you have published one shared name again with extra steps: measured on the pinned engines (run 10), every badge then reports the same anchor-name, and where each tooltip lands is decided by the divergence above, with firefox putting every tooltip under the last badge in the document and chromium and webkit putting each one under the badge in its own row.

    The other half of the same rule is what the S3 probe measured, and putting the two together is what makes this feature predictable. Publishing is anchor-name. Associating is position-anchor, and only position-anchor. Reading geometry is anchor() and anchor-size(), which take an optional name of their own, so one element can read top: anchor(--rate-badge bottom) and left: anchor(--rate-column right) and be positioned against two different anchors at once while being associated with neither of them unless you say so.

    Write all three out on the element in front of you, even where two of them look redundant today: the verb you leave implicit is the one that fails on a row you did not scroll to, in an engine you did not open.

  • Ship the chain. Write the association.

    The common read: three engines have it, so anchor positioning is a decision about whether the feature exists rather than about how you write it.

    Two facts about this feature are both true and only one of them decides what you write on Monday. Which one?

    Not the ship dates. All three engines have anchor positioning and that fact tells you nothing about the code, because every failure in this lesson happens in an engine that fully supports the feature: a tooltip at the top left of the page, a tooltip 84px past the fold, a tooltip under the last row of the table.

    The fact that decides the code is that this feature has three separate things you can leave implicit and each one has a different quiet failure. So write all three out. position-anchor on every anchored element, even where a named anchor() is doing the work today. A position-try-fallbacks chain on every anchored element, ending in a candidate small enough to fit, because a chain that runs out puts the box back where it started.

    And an anchor name that is unique per instance, generated from the data, because a component that publishes a constant name publishes it once per row.

    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("anchor-name", "--rate-badge"), CSS.supports("position-anchor", "--rate-badge"), CSS.supports("position-area", "block-end") and CSS.supports("position-try-fallbacks", "flip-block") all return true in chromium, firefox and webkit, and each matches what the same engine then does.

    What no feature query can answer is the one that mattered most here: CSS.supports("position-anchor", "auto") returns true in all three, and a query about which value a property takes when you do not set it is not a question the syntax can be asked. Take the whole lesson as one sentence: the browser will hold the relationship for you, and it will hold exactly the relationship you wrote down.