Skip to content
AtomicReps

Anchor a tooltip to its button without a positioning library

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

Level I · CSS that replaces JavaScript
  • The browser now does what your scroll listener did.

    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.

    Making that decision at every scroll step was the listener's job: read where the trigger is, decide which side the tooltip fits on, write the numbers back, and repeat when anything moves. Two CSS properties now declare the relationship instead. anchor-name marks the trigger as an anchor under a name you choose, and position-anchor on the tooltip says which named anchor it is positioned against. 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.

    Chromium, Firefox and WebKit all ship anchor positioning. During the rollout the three engines disagreed about what an unset position-anchor resolves to, which is why every listing in this lesson writes it out. The listener was the correct approach, because a stylesheet had no way to say "under that element". Everything added to it after the first version (the scroll and resize handlers, the frame throttle, the flip logic for the last row, the teardown) worked around that missing feature.

    Takeaway: The listener ran on every scroll frame to redo arithmetic the browser can now do in layout.

    The cost is small. You can no longer read the position back out of an inline style attribute, which is what your integration tests asserted on, and you no longer have the number to use in your own code. In exchange, the tooltip stays in the right place during a scroll your code never listens to.

  • With position-anchor unset, the element has no default anchor.

    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: three of the four ways to use this feature go through the one property people leave out.

    In Chromium, Firefox and WebKit 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 a popover's implicit anchor is the button that opened it. The result depends on how it was opened. In Chromium 153, a popover with position-area set and no position-anchor, opened by clicking its button or by showPopover({ source }), sits directly under that button. Opened by showPopover() with no source, it sits at the viewport's top-left corner.

    The line you would have written a year ago is that

    So write it out on every anchored element, even where a named anchor() already positions it correctly. The cost is one declaration. Without it, whether the feature works at all depends on which browser build your user happens to have.

  • position-area names one of nine regions, with 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"?

    It takes one. position-area divides the space around the anchor into a three by three grid, with the anchor in the middle cell, and puts the element in the region you name: block-end is the row under the anchor, inline-end the column beside it, block-start inline-end the corner above and after it. Physical keywords (top, bottom, left, right) and logical ones (block-start, block-end, inline-start, inline-end) both work; the logical ones keep the correct side when a locale reverses the inline direction.

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

    The browser does not keep the last one you wrote. It lays the element out at its base position and tests whether the box is contained. If not, it goes through the list in written order, applies each candidate, runs the same test, and uses the first one that passes. If every candidate overflows, the browser keeps the position it is currently using. On the first layout that is the base position, which can leave a tooltip overflowing where your first fallback was written to move it out of.

    Once a candidate has been applied, the browser keeps it until it overflows, even when an earlier option would fit again. Measured in Chromium, Firefox and WebKit with a 1000 by 500 tooltip too wide for any region around its badge: the base position alone renders at top 300, left 0, the first candidate alone at top 0, left 0, the second alone at top 142, left 280, and the full chain at top 300, left 0, the base box to the pixel.

    Two consequences follow. A chain whose last option is small enough to always fit never reverts, which is what a narrower @position-try block with its own width is for. And a chain that ran out leaves the tooltip exactly where it sat before you wrote any fallbacks. That reads as "the fallbacks are not being applied", and it 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 part of this feature that changes which position is used. In the previous question a region got a box fitted into it and an inset pair got arithmetic. The expectation that the browser keeps the box on screen came from libraries: Popper flipped by default, and Floating UI ships flip as middleware you add once.

    The fix is one line on every anchored element you ship: position-try-fallbacks with at least one candidate, the last one small enough to fit. flip-block and flip-inline need no extra block and cover both common edges. Use a named @position-try block when the flipped position needs its own width, margin or alignment; it is also what 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, with no error. The specification adds one condition: a publisher that is itself absolutely positioned counts only if it comes before the referencing element. For a badge in normal flow, the last publisher in the document wins. That conflicts with how you write components: a component that declares anchor-name in its own stylesheet publishes the name once per instance.

    Two fixes work. anchor-scope: --rate-badge on .invoice-row limits the name to each row. Or give each instance its own name from data you already have, such as the row's identifier, passed as a custom property set on the row. Set that property in the component's stylesheet instead and every badge reports the same anchor-name in all three engines: with the badges in normal flow, as in the figure, every tooltip renders under February's badge, the last one. The per-row result appears only when the badges are absolutely positioned.

    Together with the missing position-anchor from earlier, this makes the 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 anchors at once while associated with neither unless you say so.

  • Ship a fallback chain and 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 code you write. Which one?

    The ship dates do not decide it. Every failure here happens in an engine that fully supports the feature: a tooltip at the top left of the page, one 84px below the viewport, one under the last row's badge. What decides it is that three things can be left implicit, and each fails differently, with no error. So write all three out: position-anchor on every anchored element, a position-try-fallbacks chain ending in a candidate small enough to fit, and a unique anchor name per instance, generated from the data.

    A feature query gives accurate answers here, which is not true of every CSS feature: 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 all three engines, and each matches what the same engine then does. It cannot answer the question that mattered most: CSS.supports("position-anchor", "auto") returns true in all three, and no query can test which value an unset property takes.